@absolutejs/voice 0.0.22-beta.56 → 0.0.22-beta.58
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +79 -3
- package/dist/providerAdapters.d.ts +12 -1
- package/dist/resilienceRoutes.d.ts +11 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -1242,6 +1242,38 @@ const model = createVoiceProviderRouter({
|
|
|
1242
1242
|
});
|
|
1243
1243
|
```
|
|
1244
1244
|
|
|
1245
|
+
The same profile and policy shape also works for STT and TTS provider routers, so a self-hosted app can choose the fastest provider for live calls, cap cost for background work, or require a minimum quality score without hard-coding provider branches.
|
|
1246
|
+
|
|
1247
|
+
```ts
|
|
1248
|
+
const stt = createVoiceSTTProviderRouter({
|
|
1249
|
+
adapters: {
|
|
1250
|
+
deepgram,
|
|
1251
|
+
assemblyai
|
|
1252
|
+
},
|
|
1253
|
+
providerHealth: { cooldownMs: 30_000 },
|
|
1254
|
+
providerProfiles: {
|
|
1255
|
+
deepgram: { cost: 4, latencyMs: 180, quality: 0.93, timeoutMs: 1500 },
|
|
1256
|
+
assemblyai: { cost: 2, latencyMs: 650, quality: 0.88, timeoutMs: 3000 }
|
|
1257
|
+
},
|
|
1258
|
+
policy: resolveVoiceProviderRoutingPolicyPreset('latency-first')
|
|
1259
|
+
});
|
|
1260
|
+
|
|
1261
|
+
const tts = createVoiceTTSProviderRouter({
|
|
1262
|
+
adapters: {
|
|
1263
|
+
elevenlabs,
|
|
1264
|
+
openai
|
|
1265
|
+
},
|
|
1266
|
+
providerProfiles: {
|
|
1267
|
+
elevenlabs: { cost: 5, latencyMs: 220, quality: 0.94 },
|
|
1268
|
+
openai: { cost: 2, latencyMs: 320, quality: 0.87 }
|
|
1269
|
+
},
|
|
1270
|
+
policy: resolveVoiceProviderRoutingPolicyPreset('cost-cap', {
|
|
1271
|
+
maxCost: 3,
|
|
1272
|
+
minQuality: 0.85
|
|
1273
|
+
})
|
|
1274
|
+
});
|
|
1275
|
+
```
|
|
1276
|
+
|
|
1245
1277
|
## Presets
|
|
1246
1278
|
|
|
1247
1279
|
Voice now ships named runtime presets so apps can start from a useful baseline instead of hand-tuning silence and capture settings every time.
|
package/dist/index.d.ts
CHANGED
|
@@ -13,7 +13,7 @@ export { createAnthropicVoiceAssistantModel, createGeminiVoiceAssistantModel, cr
|
|
|
13
13
|
export { createVoiceProviderHealthHTMLHandler, createVoiceProviderHealthJSONHandler, createVoiceProviderHealthRoutes, renderVoiceProviderHealthHTML, summarizeVoiceProviderHealth } from './providerHealth';
|
|
14
14
|
export { buildVoiceOpsConsoleReport, createVoiceOpsConsoleRoutes, renderVoiceOpsConsoleHTML } from './opsConsoleRoutes';
|
|
15
15
|
export { createVoiceQualityRoutes, evaluateVoiceQuality, renderVoiceQualityHTML } from './qualityRoutes';
|
|
16
|
-
export { createVoiceResilienceRoutes, listVoiceRoutingEvents, renderVoiceResilienceHTML } from './resilienceRoutes';
|
|
16
|
+
export { createVoiceResilienceRoutes, createVoiceRoutingDecisionSummary, listVoiceRoutingEvents, renderVoiceResilienceHTML, summarizeVoiceRoutingDecision } from './resilienceRoutes';
|
|
17
17
|
export { createVoiceSTTProviderRouter, createVoiceTTSProviderRouter } from './providerAdapters';
|
|
18
18
|
export { buildVoiceTraceReplay, createVoiceMemoryTraceSinkDeliveryStore, createVoiceTraceHTTPSink, createVoiceMemoryTraceEventStore, createVoiceTraceSinkDeliveryId, createVoiceTraceSinkDeliveryRecord, createVoiceTraceSinkStore, createVoiceTraceEvent, createVoiceTraceEventId, deliverVoiceTraceEventsToSinks, evaluateVoiceTrace, exportVoiceTrace, filterVoiceTraceEvents, pruneVoiceTraceEvents, redactVoiceTraceEvent, redactVoiceTraceEvents, redactVoiceTraceText, renderVoiceTraceHTML, renderVoiceTraceMarkdown, resolveVoiceTraceRedactionOptions, selectVoiceTraceEventsForPrune, summarizeVoiceTrace } from './trace';
|
|
19
19
|
export { createVoiceSQLiteExternalObjectMapStore, createVoiceSQLiteIntegrationEventStore, createVoiceSQLiteReviewStore, createVoiceSQLiteRuntimeStorage, createVoiceSQLiteSessionStore, createVoiceSQLiteTaskStore, createVoiceSQLiteTraceSinkDeliveryStore, createVoiceSQLiteTraceEventStore } from './sqliteStore';
|
|
@@ -50,8 +50,8 @@ export type { AnthropicVoiceAssistantModelOptions, GeminiVoiceAssistantModelOpti
|
|
|
50
50
|
export type { VoiceProviderHealthStatus, VoiceProviderHealthSummary, VoiceProviderHealthSummaryOptions } from './providerHealth';
|
|
51
51
|
export type { VoiceOpsConsoleLink, VoiceOpsConsoleReport, VoiceOpsConsoleRoutesOptions } from './opsConsoleRoutes';
|
|
52
52
|
export type { VoiceQualityLink, VoiceQualityMetric, VoiceQualityReport, VoiceQualityRoutesOptions, VoiceQualityStatus, VoiceQualityThresholds } from './qualityRoutes';
|
|
53
|
-
export type { VoiceResilienceIOSimulator, VoiceResilienceLink, VoiceResiliencePageData, VoiceResilienceRoutesOptions, VoiceResilienceSimulationProvider, VoiceRoutingEvent, VoiceRoutingEventKind } from './resilienceRoutes';
|
|
54
|
-
export type { VoiceIOProviderRouterEvent, VoiceSTTProviderRouterOptions, VoiceTTSProviderRouterOptions } from './providerAdapters';
|
|
53
|
+
export type { VoiceResilienceIOSimulator, VoiceResilienceLink, VoiceResiliencePageData, VoiceResilienceRoutesOptions, VoiceResilienceSimulationProvider, VoiceRoutingDecisionSummary, VoiceRoutingDecisionSummaryOptions, VoiceRoutingEvent, VoiceRoutingEventKind } from './resilienceRoutes';
|
|
54
|
+
export type { VoiceIOProviderRouterEvent, VoiceIOProviderRouterOptions, VoiceIOProviderRouterPolicy, VoiceIOProviderRouterPolicyConfig, VoiceSTTProviderRouterOptions, VoiceTTSProviderRouterOptions } from './providerAdapters';
|
|
55
55
|
export type { VoiceAgent, VoiceAgentMessage, VoiceAgentMessageRole, VoiceAgentModel, VoiceAgentModelInput, VoiceAgentModelOutput, VoiceAgentOptions, VoiceAgentRunResult, VoiceAgentSquadOptions, VoiceAgentTool, VoiceAgentToolCall, VoiceAgentToolResult } from './agent';
|
|
56
56
|
export type { VoiceOpsRuntime, VoiceOpsRuntimeConfig, VoiceOpsRuntimeSummary, VoiceOpsRuntimeSinkWorkerConfig, VoiceOpsRuntimeTaskWorkerConfig, VoiceOpsRuntimeTickResult, VoiceOpsRuntimeWebhookWorkerConfig } from './opsRuntime';
|
|
57
57
|
export type { VoiceOpsPresetName, VoiceOpsPresetOverrides, VoiceResolvedOpsPreset } from './opsPresets';
|
package/dist/index.js
CHANGED
|
@@ -8493,15 +8493,37 @@ var listVoiceRoutingEvents = (events) => {
|
|
|
8493
8493
|
latencyBudgetMs: getNumber4(event.payload.latencyBudgetMs),
|
|
8494
8494
|
operation: getString7(event.payload.operation),
|
|
8495
8495
|
provider,
|
|
8496
|
+
routing: getString7(event.payload.routing),
|
|
8496
8497
|
selectedProvider: getString7(event.payload.selectedProvider),
|
|
8497
8498
|
sessionId: event.sessionId,
|
|
8498
8499
|
status: providerStatus,
|
|
8500
|
+
suppressionRemainingMs: getNumber4(event.payload.suppressionRemainingMs),
|
|
8499
8501
|
timedOut: getBoolean2(event.payload.timedOut),
|
|
8500
8502
|
turnId: event.turnId
|
|
8501
8503
|
});
|
|
8502
8504
|
}
|
|
8503
8505
|
return routingEvents.sort((left, right) => right.at - left.at);
|
|
8504
8506
|
};
|
|
8507
|
+
var summarizeVoiceRoutingDecision = (events, options = {}) => {
|
|
8508
|
+
const routingEvents = listVoiceRoutingEvents(events).filter((event) => {
|
|
8509
|
+
if (options.kind && event.kind !== options.kind) {
|
|
8510
|
+
return false;
|
|
8511
|
+
}
|
|
8512
|
+
if (options.sessionId && event.sessionId !== options.sessionId) {
|
|
8513
|
+
return false;
|
|
8514
|
+
}
|
|
8515
|
+
return true;
|
|
8516
|
+
});
|
|
8517
|
+
const limited = typeof options.limit === "number" && options.limit >= 0 ? routingEvents.slice(0, options.limit) : routingEvents;
|
|
8518
|
+
return limited[0] ?? null;
|
|
8519
|
+
};
|
|
8520
|
+
var createVoiceRoutingDecisionSummary = async (options) => {
|
|
8521
|
+
const events = await options.store.list({
|
|
8522
|
+
sessionId: options.sessionId,
|
|
8523
|
+
type: "session.error"
|
|
8524
|
+
});
|
|
8525
|
+
return summarizeVoiceRoutingDecision(events, options);
|
|
8526
|
+
};
|
|
8505
8527
|
var summarizeRoutingEvents = (events) => {
|
|
8506
8528
|
const byKind = new Map;
|
|
8507
8529
|
let errors = 0;
|
|
@@ -10953,9 +10975,14 @@ var withTimeout = async (input) => {
|
|
|
10953
10975
|
}
|
|
10954
10976
|
}
|
|
10955
10977
|
};
|
|
10978
|
+
var isVoiceProviderRoutingPolicyPreset = (policy) => policy === "balanced" || policy === "cost-cap" || policy === "cost-first" || policy === "latency-first" || policy === "quality-first";
|
|
10956
10979
|
var createResolver = (options) => {
|
|
10957
10980
|
const providerIds = Object.keys(options.adapters);
|
|
10958
10981
|
const firstProvider = providerIds[0];
|
|
10982
|
+
const policy = typeof options.policy === "string" ? isVoiceProviderRoutingPolicyPreset(options.policy) ? resolveVoiceProviderRoutingPolicyPreset(options.policy) : {
|
|
10983
|
+
strategy: options.policy
|
|
10984
|
+
} : options.policy;
|
|
10985
|
+
const strategy = policy?.strategy ?? "prefer-selected";
|
|
10959
10986
|
const healthOptions = typeof options.providerHealth === "object" ? options.providerHealth : options.providerHealth ? {} : undefined;
|
|
10960
10987
|
const healthState = new Map;
|
|
10961
10988
|
const now = () => healthOptions?.now?.() ?? Date.now();
|
|
@@ -11019,23 +11046,70 @@ var createResolver = (options) => {
|
|
|
11019
11046
|
}
|
|
11020
11047
|
return cloneHealth(provider);
|
|
11021
11048
|
};
|
|
11049
|
+
const resolveAllowedProviders = async (input) => {
|
|
11050
|
+
const allowed = typeof policy?.allowProviders === "function" ? await policy.allowProviders(input) : policy?.allowProviders;
|
|
11051
|
+
return new Set(allowed ?? providerIds);
|
|
11052
|
+
};
|
|
11053
|
+
const passesBudgetFilters = (provider) => {
|
|
11054
|
+
const profile = options.providerProfiles?.[provider];
|
|
11055
|
+
if (typeof policy?.maxCost === "number" && typeof profile?.cost === "number" && profile.cost > policy.maxCost) {
|
|
11056
|
+
return false;
|
|
11057
|
+
}
|
|
11058
|
+
if (typeof policy?.maxLatencyMs === "number" && typeof profile?.latencyMs === "number" && profile.latencyMs > policy.maxLatencyMs) {
|
|
11059
|
+
return false;
|
|
11060
|
+
}
|
|
11061
|
+
if (typeof policy?.minQuality === "number" && typeof profile?.quality === "number" && profile.quality < policy.minQuality) {
|
|
11062
|
+
return false;
|
|
11063
|
+
}
|
|
11064
|
+
return true;
|
|
11065
|
+
};
|
|
11066
|
+
const getBalancedScore = (provider) => {
|
|
11067
|
+
const profile = options.providerProfiles?.[provider];
|
|
11068
|
+
if (policy?.scoreProvider) {
|
|
11069
|
+
return policy.scoreProvider(provider, profile);
|
|
11070
|
+
}
|
|
11071
|
+
const weights = policy?.weights ?? {};
|
|
11072
|
+
return (profile?.cost ?? Number.MAX_SAFE_INTEGER) * (weights.cost ?? 1) + (profile?.latencyMs ?? Number.MAX_SAFE_INTEGER) * (weights.latencyMs ?? 0.005) + (profile?.priority ?? 0) * (weights.priority ?? 1) - (profile?.quality ?? 0) * (weights.quality ?? 10);
|
|
11073
|
+
};
|
|
11074
|
+
const sortProviders = (providers) => {
|
|
11075
|
+
if (strategy !== "prefer-cheapest" && strategy !== "prefer-fastest" && strategy !== "quality-first" && strategy !== "balanced") {
|
|
11076
|
+
return providers;
|
|
11077
|
+
}
|
|
11078
|
+
return [...providers].sort((left, right) => {
|
|
11079
|
+
const leftProfile = options.providerProfiles?.[left];
|
|
11080
|
+
const rightProfile = options.providerProfiles?.[right];
|
|
11081
|
+
if (strategy === "quality-first") {
|
|
11082
|
+
return (rightProfile?.quality ?? Number.MIN_SAFE_INTEGER) - (leftProfile?.quality ?? Number.MIN_SAFE_INTEGER) || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER) || (leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER) || (leftProfile?.cost ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.cost ?? Number.MAX_SAFE_INTEGER);
|
|
11083
|
+
}
|
|
11084
|
+
if (strategy === "balanced") {
|
|
11085
|
+
return getBalancedScore(left) - getBalancedScore(right);
|
|
11086
|
+
}
|
|
11087
|
+
const leftValue = strategy === "prefer-cheapest" ? leftProfile?.cost ?? Number.MAX_SAFE_INTEGER : leftProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
11088
|
+
const rightValue = strategy === "prefer-cheapest" ? rightProfile?.cost ?? Number.MAX_SAFE_INTEGER : rightProfile?.latencyMs ?? Number.MAX_SAFE_INTEGER;
|
|
11089
|
+
return leftValue - rightValue || (leftProfile?.priority ?? Number.MAX_SAFE_INTEGER) - (rightProfile?.priority ?? Number.MAX_SAFE_INTEGER);
|
|
11090
|
+
});
|
|
11091
|
+
};
|
|
11022
11092
|
const resolveOrder = async (input) => {
|
|
11023
|
-
const
|
|
11093
|
+
const requestedProvider = await options.selectProvider?.(input);
|
|
11094
|
+
const selectedProvider = requestedProvider ?? firstProvider;
|
|
11095
|
+
const allowedProviders = await resolveAllowedProviders(input);
|
|
11024
11096
|
const fallbackOrder = typeof options.fallback === "function" ? await options.fallback(input) : options.fallback;
|
|
11025
11097
|
const candidates = [selectedProvider, ...fallbackOrder ?? providerIds];
|
|
11026
11098
|
const seen = new Set;
|
|
11027
|
-
const
|
|
11099
|
+
const orderedCandidates = candidates.filter((provider) => {
|
|
11028
11100
|
if (!provider || seen.has(provider) || !options.adapters[provider]) {
|
|
11029
11101
|
return false;
|
|
11030
11102
|
}
|
|
11031
11103
|
seen.add(provider);
|
|
11032
11104
|
return true;
|
|
11033
11105
|
});
|
|
11106
|
+
const rankedOrder = sortProviders(orderedCandidates).filter((provider) => allowedProviders.has(provider)).filter(passesBudgetFilters);
|
|
11034
11107
|
const healthyOrder = healthOptions ? rankedOrder.filter((provider) => !isSuppressed(provider)) : rankedOrder;
|
|
11035
11108
|
const order = healthyOrder.length ? healthyOrder : rankedOrder;
|
|
11109
|
+
const preferred = strategy === "prefer-selected" && selectedProvider && allowedProviders.has(selectedProvider) && passesBudgetFilters(selectedProvider) && (!healthOptions || !isSuppressed(selectedProvider)) ? selectedProvider : order[0];
|
|
11036
11110
|
return {
|
|
11037
11111
|
order,
|
|
11038
|
-
selectedProvider:
|
|
11112
|
+
selectedProvider: preferred
|
|
11039
11113
|
};
|
|
11040
11114
|
};
|
|
11041
11115
|
const emit = async (event, input) => {
|
|
@@ -14028,6 +14102,7 @@ export {
|
|
|
14028
14102
|
summarizeVoiceTrace,
|
|
14029
14103
|
summarizeVoiceSessions,
|
|
14030
14104
|
summarizeVoiceSessionReplay,
|
|
14105
|
+
summarizeVoiceRoutingDecision,
|
|
14031
14106
|
summarizeVoiceProviderHealth,
|
|
14032
14107
|
summarizeVoiceOpsTasks,
|
|
14033
14108
|
summarizeVoiceOpsTaskQueue,
|
|
@@ -14142,6 +14217,7 @@ export {
|
|
|
14142
14217
|
createVoiceSQLiteIntegrationEventStore,
|
|
14143
14218
|
createVoiceSQLiteExternalObjectMapStore,
|
|
14144
14219
|
createVoiceS3ReviewStore,
|
|
14220
|
+
createVoiceRoutingDecisionSummary,
|
|
14145
14221
|
createVoiceReviewSavedEvent,
|
|
14146
14222
|
createVoiceResilienceRoutes,
|
|
14147
14223
|
createVoiceRedisTaskLeaseCoordinator,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { STTAdapter, STTAdapterOpenOptions, TTSAdapter, TTSAdapterOpenOptions } from './types';
|
|
2
|
-
import type { VoiceProviderRouterHealthOptions, VoiceProviderRouterProviderHealth, VoiceProviderRouterProviderProfile } from './modelAdapters';
|
|
2
|
+
import type { VoiceProviderRouterHealthOptions, VoiceProviderRouterProviderHealth, VoiceProviderRouterProviderProfile, VoiceProviderRouterPolicyPreset, VoiceProviderRouterPolicyWeights, VoiceProviderRouterStrategy } from './modelAdapters';
|
|
3
3
|
type MaybePromise<T> = T | Promise<T>;
|
|
4
4
|
type VoiceIOProviderKind = 'stt' | 'tts';
|
|
5
5
|
type VoiceIOProviderStatus = 'error' | 'fallback' | 'success';
|
|
@@ -20,11 +20,22 @@ export type VoiceIOProviderRouterEvent<TProvider extends string = string> = {
|
|
|
20
20
|
suppressedUntil?: number;
|
|
21
21
|
timedOut?: boolean;
|
|
22
22
|
};
|
|
23
|
+
export type VoiceIOProviderRouterPolicyConfig<TOpenOptions = unknown, TProvider extends string = string> = {
|
|
24
|
+
allowProviders?: readonly TProvider[] | ((input: TOpenOptions) => MaybePromise<readonly TProvider[]>);
|
|
25
|
+
maxCost?: number;
|
|
26
|
+
maxLatencyMs?: number;
|
|
27
|
+
minQuality?: number;
|
|
28
|
+
scoreProvider?: (provider: TProvider, profile: VoiceProviderRouterProviderProfile | undefined) => number;
|
|
29
|
+
strategy?: VoiceProviderRouterStrategy;
|
|
30
|
+
weights?: VoiceProviderRouterPolicyWeights;
|
|
31
|
+
};
|
|
32
|
+
export type VoiceIOProviderRouterPolicy<TOpenOptions = unknown, TProvider extends string = string> = VoiceProviderRouterStrategy | VoiceProviderRouterPolicyPreset | VoiceIOProviderRouterPolicyConfig<TOpenOptions, TProvider>;
|
|
23
33
|
export type VoiceIOProviderRouterOptions<TProvider extends string, TAdapter, TOpenOptions> = {
|
|
24
34
|
adapters: Partial<Record<TProvider, TAdapter>>;
|
|
25
35
|
fallback?: readonly TProvider[] | ((input: TOpenOptions) => MaybePromise<readonly TProvider[]>);
|
|
26
36
|
isProviderError?: (error: unknown, provider: TProvider) => boolean;
|
|
27
37
|
onProviderEvent?: (event: VoiceIOProviderRouterEvent<TProvider>, input: TOpenOptions) => Promise<void> | void;
|
|
38
|
+
policy?: VoiceIOProviderRouterPolicy<TOpenOptions, TProvider>;
|
|
28
39
|
providerHealth?: boolean | VoiceProviderRouterHealthOptions;
|
|
29
40
|
providerProfiles?: Partial<Record<TProvider, VoiceProviderRouterProviderProfile>>;
|
|
30
41
|
selectProvider?: (input: TOpenOptions) => MaybePromise<TProvider | undefined>;
|
|
@@ -13,12 +13,21 @@ export type VoiceRoutingEvent = {
|
|
|
13
13
|
latencyBudgetMs?: number;
|
|
14
14
|
operation?: string;
|
|
15
15
|
provider?: string;
|
|
16
|
+
routing?: string;
|
|
16
17
|
selectedProvider?: string;
|
|
17
18
|
sessionId: string;
|
|
18
19
|
status?: string;
|
|
20
|
+
suppressionRemainingMs?: number;
|
|
19
21
|
timedOut: boolean;
|
|
20
22
|
turnId?: string;
|
|
21
23
|
};
|
|
24
|
+
export type VoiceRoutingDecisionSummary = VoiceRoutingEvent;
|
|
25
|
+
export type VoiceRoutingDecisionSummaryOptions = {
|
|
26
|
+
kind?: VoiceRoutingEventKind;
|
|
27
|
+
limit?: number;
|
|
28
|
+
sessionId?: string;
|
|
29
|
+
store: VoiceTraceEventStore;
|
|
30
|
+
};
|
|
22
31
|
export type VoiceResilienceLink = {
|
|
23
32
|
href: string;
|
|
24
33
|
label: string;
|
|
@@ -63,6 +72,8 @@ export type VoiceResilienceRoutesOptions = {
|
|
|
63
72
|
ttsSimulation?: VoiceResilienceIOSimulator<string>;
|
|
64
73
|
};
|
|
65
74
|
export declare const listVoiceRoutingEvents: (events: StoredVoiceTraceEvent[]) => VoiceRoutingEvent[];
|
|
75
|
+
export declare const summarizeVoiceRoutingDecision: (events: StoredVoiceTraceEvent[], options?: Omit<VoiceRoutingDecisionSummaryOptions, "store">) => VoiceRoutingDecisionSummary | null;
|
|
76
|
+
export declare const createVoiceRoutingDecisionSummary: (options: VoiceRoutingDecisionSummaryOptions) => Promise<VoiceRoutingDecisionSummary | null>;
|
|
66
77
|
export declare const renderVoiceResilienceHTML: (input: VoiceResiliencePageData) => string;
|
|
67
78
|
export declare const createVoiceResilienceRoutes: (options: VoiceResilienceRoutesOptions) => Elysia<"", {
|
|
68
79
|
decorator: {};
|