@tuturuuu/ai 0.0.10
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 +76 -0
- package/package.json +106 -0
- package/src/api-key-hash.ts +28 -0
- package/src/calendar/events.ts +34 -0
- package/src/calendar/route.ts +114 -0
- package/src/chat/credit-source.ts +1 -0
- package/src/chat/google/chat-request-schema.ts +150 -0
- package/src/chat/google/default-system-instruction.ts +198 -0
- package/src/chat/google/message-file-processing.ts +212 -0
- package/src/chat/google/mira-step-preparation.ts +221 -0
- package/src/chat/google/new/route.ts +368 -0
- package/src/chat/google/route-auth.ts +81 -0
- package/src/chat/google/route-chat-resolution.ts +98 -0
- package/src/chat/google/route-credits.ts +61 -0
- package/src/chat/google/route-message-preparation.ts +331 -0
- package/src/chat/google/route-mira-runtime.ts +206 -0
- package/src/chat/google/route.ts +632 -0
- package/src/chat/google/stream-finish-persistence.ts +722 -0
- package/src/chat/google/summary/route.ts +153 -0
- package/src/chat/mira-render-ui-policy.ts +540 -0
- package/src/chat/mira-system-instruction.ts +484 -0
- package/src/chat-sdk/adapters.ts +389 -0
- package/src/chat-sdk/registry.ts +197 -0
- package/src/chat-sdk.ts +33 -0
- package/src/core.ts +3 -0
- package/src/credits/cap-output-tokens.ts +90 -0
- package/src/credits/check-credits.ts +232 -0
- package/src/credits/constants.ts +30 -0
- package/src/credits/index.ts +46 -0
- package/src/credits/model-mapping.ts +92 -0
- package/src/credits/reservations.ts +514 -0
- package/src/credits/resolve-plan-model.ts +219 -0
- package/src/credits/sync-gateway-models.ts +351 -0
- package/src/credits/types.ts +109 -0
- package/src/credits/use-ai-credits.ts +3 -0
- package/src/embeddings/metered.ts +283 -0
- package/src/executions/route.ts +137 -0
- package/src/generate/route.ts +411 -0
- package/src/hooks.ts +7 -0
- package/src/meetings/summary/route.ts +7 -0
- package/src/meetings/transcription/route.ts +134 -0
- package/src/memory/client.ts +158 -0
- package/src/memory/config.ts +38 -0
- package/src/memory/index.ts +32 -0
- package/src/memory/ingest.ts +51 -0
- package/src/memory/middleware.ts +35 -0
- package/src/memory/operations.ts +480 -0
- package/src/memory/scope.ts +102 -0
- package/src/memory/settings.ts +121 -0
- package/src/memory/types.ts +101 -0
- package/src/memory/workspace.ts +36 -0
- package/src/memory.ts +1 -0
- package/src/mind/patch.ts +146 -0
- package/src/mind/route.ts +687 -0
- package/src/mind/tools.ts +1500 -0
- package/src/mind/types.ts +20 -0
- package/src/object/core.ts +3 -0
- package/src/object/flashcards/route.ts +140 -0
- package/src/object/quizzes/explanation/route.ts +145 -0
- package/src/object/quizzes/route.ts +142 -0
- package/src/object/types.ts +187 -0
- package/src/object/year-plan/route.ts +196 -0
- package/src/react.ts +1 -0
- package/src/scheduling/algorithm.ts +791 -0
- package/src/scheduling/default.ts +36 -0
- package/src/scheduling/duration-optimizer.ts +689 -0
- package/src/scheduling/index.ts +79 -0
- package/src/scheduling/priority-calculator.ts +187 -0
- package/src/scheduling/recurrence-calculator.ts +621 -0
- package/src/scheduling/templates.ts +892 -0
- package/src/scheduling/types.ts +136 -0
- package/src/scheduling/web-adapter.ts +308 -0
- package/src/scheduling.ts +6 -0
- package/src/supported-actions.ts +1 -0
- package/src/supported-providers.ts +6 -0
- package/src/tools/context-builder.ts +372 -0
- package/src/tools/core.ts +1 -0
- package/src/tools/definitions/calendar.ts +106 -0
- package/src/tools/definitions/finance.ts +197 -0
- package/src/tools/definitions/image.ts +74 -0
- package/src/tools/definitions/memory.ts +83 -0
- package/src/tools/definitions/meta.ts +154 -0
- package/src/tools/definitions/render-ui.ts +81 -0
- package/src/tools/definitions/tasks.ts +343 -0
- package/src/tools/definitions/time-tracking.ts +381 -0
- package/src/tools/definitions/workspace-context.ts +45 -0
- package/src/tools/definitions/workspace-user-chat.ts +111 -0
- package/src/tools/executors/calendar.ts +371 -0
- package/src/tools/executors/chat.ts +15 -0
- package/src/tools/executors/finance.ts +638 -0
- package/src/tools/executors/helpers/encryption.ts +107 -0
- package/src/tools/executors/image.ts +247 -0
- package/src/tools/executors/markitdown.ts +684 -0
- package/src/tools/executors/memory.ts +277 -0
- package/src/tools/executors/parallel-checks.ts +176 -0
- package/src/tools/executors/qr.ts +170 -0
- package/src/tools/executors/scope-helpers.ts +192 -0
- package/src/tools/executors/search.ts +149 -0
- package/src/tools/executors/settings.ts +40 -0
- package/src/tools/executors/tasks.ts +1087 -0
- package/src/tools/executors/theme.ts +23 -0
- package/src/tools/executors/timer/timer-categories-executor.ts +110 -0
- package/src/tools/executors/timer/timer-category-mutations.ts +240 -0
- package/src/tools/executors/timer/timer-goal-mutations.ts +323 -0
- package/src/tools/executors/timer/timer-goals-executor.ts +272 -0
- package/src/tools/executors/timer/timer-helpers.ts +372 -0
- package/src/tools/executors/timer/timer-mutation-schemas.ts +160 -0
- package/src/tools/executors/timer/timer-mutation-types.ts +212 -0
- package/src/tools/executors/timer/timer-mutations.ts +19 -0
- package/src/tools/executors/timer/timer-queries.ts +18 -0
- package/src/tools/executors/timer/timer-session-lifecycle.ts +299 -0
- package/src/tools/executors/timer/timer-session-mutations.ts +10 -0
- package/src/tools/executors/timer/timer-session-queries.ts +153 -0
- package/src/tools/executors/timer/timer-session-updates.ts +200 -0
- package/src/tools/executors/timer/timer-sessions-executor.ts +91 -0
- package/src/tools/executors/timer/timer-stats-executor.ts +157 -0
- package/src/tools/executors/timer.ts +22 -0
- package/src/tools/executors/user.ts +60 -0
- package/src/tools/executors/workspace.ts +135 -0
- package/src/tools/json-render-catalog.ts +875 -0
- package/src/tools/mira-tool-definitions.ts +55 -0
- package/src/tools/mira-tool-dispatcher.ts +265 -0
- package/src/tools/mira-tool-metadata.ts +164 -0
- package/src/tools/mira-tool-names.ts +95 -0
- package/src/tools/mira-tool-render-ui.ts +54 -0
- package/src/tools/mira-tool-types.ts +17 -0
- package/src/tools/mira-tools.ts +167 -0
- package/src/tools/normalize-render-ui-input.ts +321 -0
- package/src/tools/workspace-context.ts +233 -0
- package/src/types.ts +38 -0
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
import type { PermissionId } from '@tuturuuu/types';
|
|
2
|
+
import { DEV_MODE } from '@tuturuuu/utils/constants';
|
|
3
|
+
import type { Tool, ToolSet } from 'ai';
|
|
4
|
+
import { createStreamRenderUiTool } from './definitions/render-ui';
|
|
5
|
+
import { miraToolDefinitions } from './mira-tool-definitions';
|
|
6
|
+
import { executeMiraTool } from './mira-tool-dispatcher';
|
|
7
|
+
import {
|
|
8
|
+
MIRA_TOOL_DIRECTORY,
|
|
9
|
+
MIRA_TOOL_PERMISSIONS,
|
|
10
|
+
} from './mira-tool-metadata';
|
|
11
|
+
import type { MiraToolName } from './mira-tool-names';
|
|
12
|
+
import {
|
|
13
|
+
buildRenderUiFailsafeSpec,
|
|
14
|
+
isRenderableRenderUiSpec,
|
|
15
|
+
} from './mira-tool-render-ui';
|
|
16
|
+
import type { MiraToolContext } from './mira-tool-types';
|
|
17
|
+
|
|
18
|
+
export type { MiraToolContext } from './mira-tool-types';
|
|
19
|
+
export type { MiraToolName };
|
|
20
|
+
export {
|
|
21
|
+
executeMiraTool,
|
|
22
|
+
MIRA_TOOL_DIRECTORY,
|
|
23
|
+
MIRA_TOOL_PERMISSIONS,
|
|
24
|
+
miraToolDefinitions,
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function createMiraStreamTools(
|
|
28
|
+
ctx: MiraToolContext,
|
|
29
|
+
withoutPermission?: (p: PermissionId) => boolean,
|
|
30
|
+
getSteps?: () => unknown[]
|
|
31
|
+
): ToolSet {
|
|
32
|
+
const tools: ToolSet = {};
|
|
33
|
+
let renderUiInvalidAttempts = 0;
|
|
34
|
+
|
|
35
|
+
// Create a per-stream render_ui tool with stateful preprocessor.
|
|
36
|
+
// The preprocessor auto-populates a context-aware fallback on the first
|
|
37
|
+
// empty-elements call using data tools found in previous steps.
|
|
38
|
+
const { toolDef: streamRenderUiDef, wasAutoPopulated } =
|
|
39
|
+
createStreamRenderUiTool(getSteps);
|
|
40
|
+
|
|
41
|
+
const definitionEntries = Object.entries(miraToolDefinitions) as Array<
|
|
42
|
+
[
|
|
43
|
+
keyof typeof miraToolDefinitions,
|
|
44
|
+
(typeof miraToolDefinitions)[keyof typeof miraToolDefinitions],
|
|
45
|
+
]
|
|
46
|
+
>;
|
|
47
|
+
|
|
48
|
+
for (const [name, def] of definitionEntries) {
|
|
49
|
+
const requiredPerm = MIRA_TOOL_PERMISSIONS[name];
|
|
50
|
+
let isMissingPermission = false;
|
|
51
|
+
let missingPermissionsStr = '';
|
|
52
|
+
|
|
53
|
+
if (requiredPerm && withoutPermission) {
|
|
54
|
+
if (Array.isArray(requiredPerm)) {
|
|
55
|
+
const missing = requiredPerm.filter((p) => withoutPermission(p));
|
|
56
|
+
if (missing.length > 0) {
|
|
57
|
+
isMissingPermission = true;
|
|
58
|
+
missingPermissionsStr = missing.join(', ');
|
|
59
|
+
}
|
|
60
|
+
} else if (withoutPermission(requiredPerm)) {
|
|
61
|
+
isMissingPermission = true;
|
|
62
|
+
missingPermissionsStr = requiredPerm;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (isMissingPermission) {
|
|
67
|
+
tools[name] = {
|
|
68
|
+
...def,
|
|
69
|
+
execute: async () => ({
|
|
70
|
+
ok: false,
|
|
71
|
+
error: `You do not have the required permissions to use this tool. Missing permission(s): ${missingPermissionsStr}. Please inform the user.`,
|
|
72
|
+
}),
|
|
73
|
+
} as Tool;
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (name === 'render_ui') {
|
|
78
|
+
if (!DEV_MODE) {
|
|
79
|
+
continue; // Skip adding render_ui in non-DEV_MODE
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
tools[name] = {
|
|
83
|
+
// Use the per-stream definition (stateful preprocessor + refinement)
|
|
84
|
+
// instead of the shared singleton definition.
|
|
85
|
+
...streamRenderUiDef,
|
|
86
|
+
execute: async (args: Record<string, unknown>) => {
|
|
87
|
+
// Check if the preprocessor auto-populated this spec (the model
|
|
88
|
+
// sent empty elements repeatedly and we injected a placeholder).
|
|
89
|
+
if (wasAutoPopulated()) {
|
|
90
|
+
return {
|
|
91
|
+
spec: args,
|
|
92
|
+
autoPopulatedFallback: true,
|
|
93
|
+
autoRecoveredFromInvalidSpec: true,
|
|
94
|
+
forcedFromRecoveryLoop: true,
|
|
95
|
+
warning:
|
|
96
|
+
'render_ui was auto-recovered from empty elements. A context-aware fallback was injected based on previously-called data tools.',
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isRenderableRenderUiSpec(args)) {
|
|
101
|
+
renderUiInvalidAttempts = 0;
|
|
102
|
+
return { spec: args };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// If we reach here, the spec passed Zod validation but still
|
|
106
|
+
// fails our stricter isRenderableRenderUiSpec check (e.g. root key
|
|
107
|
+
// not matching an element, or root element is not an object).
|
|
108
|
+
renderUiInvalidAttempts += 1;
|
|
109
|
+
const isRepeatedInvalidAttempt = renderUiInvalidAttempts > 1;
|
|
110
|
+
|
|
111
|
+
// Build a targeted diagnosis of what went wrong.
|
|
112
|
+
const diagnosisParts: string[] = [];
|
|
113
|
+
if (typeof args.root !== 'string' || args.root.length === 0) {
|
|
114
|
+
diagnosisParts.push('`root` is missing or not a string');
|
|
115
|
+
}
|
|
116
|
+
const elements = args.elements;
|
|
117
|
+
if (
|
|
118
|
+
!elements ||
|
|
119
|
+
typeof elements !== 'object' ||
|
|
120
|
+
Array.isArray(elements)
|
|
121
|
+
) {
|
|
122
|
+
diagnosisParts.push('`elements` is missing or not an object');
|
|
123
|
+
} else if (Object.keys(elements as object).length === 0) {
|
|
124
|
+
diagnosisParts.push(
|
|
125
|
+
'`elements` is empty — you must define at least elements[root]'
|
|
126
|
+
);
|
|
127
|
+
} else if (
|
|
128
|
+
typeof args.root === 'string' &&
|
|
129
|
+
!(args.root in (elements as Record<string, unknown>))
|
|
130
|
+
) {
|
|
131
|
+
diagnosisParts.push(
|
|
132
|
+
`elements["${args.root}"] does not exist — the root element ID must be a key in elements`
|
|
133
|
+
);
|
|
134
|
+
}
|
|
135
|
+
const diagnosis =
|
|
136
|
+
diagnosisParts.length > 0
|
|
137
|
+
? ` Diagnosis: ${diagnosisParts.join('; ')}.`
|
|
138
|
+
: '';
|
|
139
|
+
|
|
140
|
+
const stopMessage = isRepeatedInvalidAttempt
|
|
141
|
+
? ' STOP retrying render_ui. Respond with plain text/markdown instead.'
|
|
142
|
+
: ` Fix: elements MUST contain the root element. Minimal working example: { "root": "r", "elements": { "r": { "type": "Card", "props": { "title": "Result" }, "children": ["t"] }, "t": { "type": "Text", "props": { "content": "Your content here" }, "children": [] } } }. Retry ONE more time with populated elements.`;
|
|
143
|
+
|
|
144
|
+
return {
|
|
145
|
+
spec: buildRenderUiFailsafeSpec(args),
|
|
146
|
+
autoRecoveredFromInvalidSpec: true,
|
|
147
|
+
...(isRepeatedInvalidAttempt
|
|
148
|
+
? { forcedFromRecoveryLoop: true }
|
|
149
|
+
: {}),
|
|
150
|
+
warning: `Invalid render_ui spec.${diagnosis}${stopMessage}`,
|
|
151
|
+
};
|
|
152
|
+
},
|
|
153
|
+
} as Tool;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
tools[name] = {
|
|
158
|
+
...def,
|
|
159
|
+
execute: async (
|
|
160
|
+
args: Record<string, unknown>,
|
|
161
|
+
options?: { abortSignal?: AbortSignal }
|
|
162
|
+
) => executeMiraTool(name, args, ctx, options),
|
|
163
|
+
} as Tool;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
return tools;
|
|
167
|
+
}
|
|
@@ -0,0 +1,321 @@
|
|
|
1
|
+
import { hasToolCallInSteps } from '../chat/mira-render-ui-policy';
|
|
2
|
+
|
|
3
|
+
type AnyRecord = Record<string, unknown>;
|
|
4
|
+
|
|
5
|
+
function isRecord(value: unknown): value is AnyRecord {
|
|
6
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function safeParseJson(value: string): unknown {
|
|
10
|
+
try {
|
|
11
|
+
return JSON.parse(value);
|
|
12
|
+
} catch {
|
|
13
|
+
return null;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function normalizeElement(raw: unknown): unknown {
|
|
18
|
+
if (!isRecord(raw)) return raw;
|
|
19
|
+
|
|
20
|
+
const element: AnyRecord = { ...raw };
|
|
21
|
+
const rawProps = element.props;
|
|
22
|
+
const props = isRecord(rawProps) ? { ...rawProps } : {};
|
|
23
|
+
|
|
24
|
+
// Common model mistake: put bindings under props.bindings instead of element.bindings.
|
|
25
|
+
if (!element.bindings && isRecord(props.bindings)) {
|
|
26
|
+
element.bindings = props.bindings;
|
|
27
|
+
delete props.bindings;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
element.props = props;
|
|
31
|
+
element.children = Array.isArray(element.children) ? element.children : [];
|
|
32
|
+
|
|
33
|
+
return element;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
function normalizeSpecLike(raw: unknown): unknown {
|
|
37
|
+
if (!isRecord(raw)) return raw;
|
|
38
|
+
if (typeof raw.root !== 'string' || !isRecord(raw.elements)) return raw;
|
|
39
|
+
|
|
40
|
+
const normalizedElements: AnyRecord = {};
|
|
41
|
+
for (const [id, element] of Object.entries(raw.elements)) {
|
|
42
|
+
normalizedElements[id] = normalizeElement(element);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
...raw,
|
|
47
|
+
elements: normalizedElements,
|
|
48
|
+
};
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getCandidates(value: AnyRecord): unknown[] {
|
|
52
|
+
const candidates: unknown[] = [];
|
|
53
|
+
const keys = [
|
|
54
|
+
'json_schema',
|
|
55
|
+
'spec',
|
|
56
|
+
'schema',
|
|
57
|
+
'output',
|
|
58
|
+
'result',
|
|
59
|
+
'data',
|
|
60
|
+
'payload',
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
for (const key of keys) {
|
|
64
|
+
if (key in value) candidates.push(value[key]);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (typeof value.json === 'string') {
|
|
68
|
+
const parsed = safeParseJson(value.json);
|
|
69
|
+
if (parsed !== null) candidates.push(parsed);
|
|
70
|
+
} else if (value.json !== undefined) {
|
|
71
|
+
candidates.push(value.json);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return candidates;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* Detect whether a normalized spec-like value has an empty `elements` record.
|
|
79
|
+
*/
|
|
80
|
+
function hasEmptyElements(value: unknown): boolean {
|
|
81
|
+
if (!isRecord(value)) return false;
|
|
82
|
+
if (typeof value.root !== 'string') return false;
|
|
83
|
+
if (!isRecord(value.elements)) return false;
|
|
84
|
+
return Object.keys(value.elements).length === 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Mapping from data-fetching tool names to smart fallback UI specs.
|
|
89
|
+
* Each entry returns `{ root, elements }` that can be merged onto the spec.
|
|
90
|
+
*/
|
|
91
|
+
function buildContextAwareFallback(
|
|
92
|
+
steps: unknown[]
|
|
93
|
+
): { root: string; elements: AnyRecord } | null {
|
|
94
|
+
// Priority order: tasks > time-tracking > calendar > finance
|
|
95
|
+
const taskTools = ['get_my_tasks', 'create_task', 'update_task'];
|
|
96
|
+
const timeTools = [
|
|
97
|
+
'start_timer',
|
|
98
|
+
'stop_timer',
|
|
99
|
+
'list_time_tracking_sessions',
|
|
100
|
+
'list_time_tracking_categories',
|
|
101
|
+
'create_time_tracking_category',
|
|
102
|
+
'update_time_tracking_category',
|
|
103
|
+
'delete_time_tracking_category',
|
|
104
|
+
'get_time_tracking_session',
|
|
105
|
+
'get_time_tracker_stats',
|
|
106
|
+
'get_time_tracker_goals',
|
|
107
|
+
'create_time_tracker_goal',
|
|
108
|
+
'update_time_tracker_goal',
|
|
109
|
+
'delete_time_tracker_goal',
|
|
110
|
+
'create_time_tracking_entry',
|
|
111
|
+
];
|
|
112
|
+
const calendarTools = [
|
|
113
|
+
'get_upcoming_events',
|
|
114
|
+
'create_calendar_event',
|
|
115
|
+
'update_calendar_event',
|
|
116
|
+
];
|
|
117
|
+
const financeTools = [
|
|
118
|
+
'get_spending_summary',
|
|
119
|
+
'list_transactions',
|
|
120
|
+
'create_transaction',
|
|
121
|
+
];
|
|
122
|
+
|
|
123
|
+
const hasAnyToolCall = (toolNames: string[]) =>
|
|
124
|
+
toolNames.some((name) => hasToolCallInSteps(steps, name));
|
|
125
|
+
|
|
126
|
+
if (hasAnyToolCall(taskTools)) {
|
|
127
|
+
return {
|
|
128
|
+
root: 'auto_tasks',
|
|
129
|
+
elements: {
|
|
130
|
+
auto_tasks: {
|
|
131
|
+
type: 'MyTasks',
|
|
132
|
+
props: { showSummary: true, showFilters: true },
|
|
133
|
+
children: [],
|
|
134
|
+
},
|
|
135
|
+
},
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
if (hasAnyToolCall(timeTools)) {
|
|
140
|
+
return {
|
|
141
|
+
root: 'auto_time',
|
|
142
|
+
elements: {
|
|
143
|
+
auto_time: {
|
|
144
|
+
type: 'TimeTrackingStats',
|
|
145
|
+
props: { period: 'last_7_days' },
|
|
146
|
+
children: [],
|
|
147
|
+
},
|
|
148
|
+
},
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (hasAnyToolCall(calendarTools)) {
|
|
153
|
+
return {
|
|
154
|
+
root: 'auto_calendar',
|
|
155
|
+
elements: {
|
|
156
|
+
auto_calendar: {
|
|
157
|
+
type: 'Card',
|
|
158
|
+
props: { title: 'Upcoming Events' },
|
|
159
|
+
children: ['auto_calendar_text'],
|
|
160
|
+
},
|
|
161
|
+
auto_calendar_text: {
|
|
162
|
+
type: 'Text',
|
|
163
|
+
props: {
|
|
164
|
+
content:
|
|
165
|
+
'Your upcoming events are shown above. Ask me to create, update, or check specific events.',
|
|
166
|
+
},
|
|
167
|
+
children: [],
|
|
168
|
+
},
|
|
169
|
+
},
|
|
170
|
+
};
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (hasAnyToolCall(financeTools)) {
|
|
174
|
+
return {
|
|
175
|
+
root: 'auto_finance',
|
|
176
|
+
elements: {
|
|
177
|
+
auto_finance: {
|
|
178
|
+
type: 'Card',
|
|
179
|
+
props: { title: 'Finance Summary' },
|
|
180
|
+
children: ['auto_finance_text'],
|
|
181
|
+
},
|
|
182
|
+
auto_finance_text: {
|
|
183
|
+
type: 'Text',
|
|
184
|
+
props: {
|
|
185
|
+
content:
|
|
186
|
+
'Your finance data is shown above. Ask me for more details or to manage transactions.',
|
|
187
|
+
},
|
|
188
|
+
children: [],
|
|
189
|
+
},
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
return null;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Build a minimal valid spec so the model output passes Zod validation.
|
|
199
|
+
* Uses context from previous tool calls when available, otherwise falls back
|
|
200
|
+
* to a generic Callout.
|
|
201
|
+
*/
|
|
202
|
+
function autoPopulateEmptyElements(
|
|
203
|
+
value: AnyRecord,
|
|
204
|
+
steps: unknown[]
|
|
205
|
+
): AnyRecord {
|
|
206
|
+
const contextFallback = buildContextAwareFallback(steps);
|
|
207
|
+
if (contextFallback) {
|
|
208
|
+
return {
|
|
209
|
+
...value,
|
|
210
|
+
root: contextFallback.root,
|
|
211
|
+
elements: contextFallback.elements,
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
// Generic fallback when no data tools were called.
|
|
216
|
+
const root =
|
|
217
|
+
typeof value.root === 'string' && value.root.trim().length > 0
|
|
218
|
+
? value.root.trim()
|
|
219
|
+
: 'auto_root';
|
|
220
|
+
|
|
221
|
+
return {
|
|
222
|
+
...value,
|
|
223
|
+
root,
|
|
224
|
+
elements: {
|
|
225
|
+
[root]: {
|
|
226
|
+
type: 'Callout',
|
|
227
|
+
props: {
|
|
228
|
+
title: 'UI unavailable',
|
|
229
|
+
variant: 'warning',
|
|
230
|
+
content:
|
|
231
|
+
'The assistant attempted to render interactive UI but the specification was incomplete. Please try your request again.',
|
|
232
|
+
},
|
|
233
|
+
children: [],
|
|
234
|
+
},
|
|
235
|
+
},
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/**
|
|
240
|
+
* Normalize model-generated render_ui inputs before zod validation.
|
|
241
|
+
* Handles common wrappers and structural mistakes while preserving unknown fields.
|
|
242
|
+
*/
|
|
243
|
+
export function normalizeRenderUiInputForTool(input: unknown): unknown {
|
|
244
|
+
const queue: unknown[] = [input];
|
|
245
|
+
const visited = new WeakSet<object>();
|
|
246
|
+
|
|
247
|
+
while (queue.length > 0) {
|
|
248
|
+
const current = queue.shift();
|
|
249
|
+
if (current === null || current === undefined) continue;
|
|
250
|
+
|
|
251
|
+
if (typeof current === 'string') {
|
|
252
|
+
const parsed = safeParseJson(current);
|
|
253
|
+
if (parsed !== null) {
|
|
254
|
+
queue.push(parsed);
|
|
255
|
+
}
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
if (!isRecord(current)) continue;
|
|
260
|
+
if (visited.has(current)) continue;
|
|
261
|
+
visited.add(current);
|
|
262
|
+
|
|
263
|
+
const normalized = normalizeSpecLike(current);
|
|
264
|
+
if (normalized !== current) return normalized;
|
|
265
|
+
|
|
266
|
+
queue.push(...getCandidates(current));
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return input;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/**
|
|
273
|
+
* Create a stateful preprocessor for render_ui that tracks empty-elements
|
|
274
|
+
* attempts per stream.
|
|
275
|
+
*
|
|
276
|
+
* When the model sends empty `elements`, the preprocessor immediately
|
|
277
|
+
* auto-populates a context-aware fallback (using data tools found in
|
|
278
|
+
* previous steps) on the **first** empty-elements call. Waiting for a
|
|
279
|
+
* Zod rejection first is proven to be wasteful — the model never
|
|
280
|
+
* self-corrects after rejection.
|
|
281
|
+
*
|
|
282
|
+
* @param getSteps – Optional callback returning the current steps array.
|
|
283
|
+
* When provided, the auto-populated fallback is context-aware (e.g.
|
|
284
|
+
* MyTasks when get_my_tasks was called). When omitted, falls back to a
|
|
285
|
+
* generic Callout.
|
|
286
|
+
*
|
|
287
|
+
* Returns an object with the preprocessor function and a `wasAutoPopulated()`
|
|
288
|
+
* check that the execute handler can call to detect auto-populated specs.
|
|
289
|
+
*/
|
|
290
|
+
export function createRenderUiPreprocessor(getSteps?: () => unknown[]): {
|
|
291
|
+
preprocess: (val: unknown) => unknown;
|
|
292
|
+
wasAutoPopulated: () => boolean;
|
|
293
|
+
} {
|
|
294
|
+
let emptyElementsAttempts = 0;
|
|
295
|
+
let lastCallAutoPopulated = false;
|
|
296
|
+
|
|
297
|
+
return {
|
|
298
|
+
preprocess(val: unknown): unknown {
|
|
299
|
+
lastCallAutoPopulated = false;
|
|
300
|
+
const normalized = normalizeRenderUiInputForTool(val);
|
|
301
|
+
|
|
302
|
+
if (hasEmptyElements(normalized)) {
|
|
303
|
+
emptyElementsAttempts += 1;
|
|
304
|
+
|
|
305
|
+
// Auto-populate on the first empty-elements call. The model never
|
|
306
|
+
// self-corrects after Zod rejection, so the first rejection just
|
|
307
|
+
// wastes a step and tokens.
|
|
308
|
+
if (emptyElementsAttempts === 1) {
|
|
309
|
+
lastCallAutoPopulated = true;
|
|
310
|
+
const steps = getSteps?.() ?? [];
|
|
311
|
+
return autoPopulateEmptyElements(normalized as AnyRecord, steps);
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
return normalized;
|
|
316
|
+
},
|
|
317
|
+
wasAutoPopulated(): boolean {
|
|
318
|
+
return lastCallAutoPopulated;
|
|
319
|
+
},
|
|
320
|
+
};
|
|
321
|
+
}
|
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
import type { TypedSupabaseClient } from '@tuturuuu/supabase/next/client';
|
|
2
|
+
import {
|
|
3
|
+
normalizeWorkspaceContextId,
|
|
4
|
+
PERSONAL_WORKSPACE_SLUG,
|
|
5
|
+
} from '@tuturuuu/utils/constants';
|
|
6
|
+
import type { MiraToolContext } from './mira-tool-types';
|
|
7
|
+
|
|
8
|
+
type WorkspaceMembershipRow = {
|
|
9
|
+
ws_id: string | null;
|
|
10
|
+
workspaces:
|
|
11
|
+
| {
|
|
12
|
+
id: string;
|
|
13
|
+
name: string | null;
|
|
14
|
+
personal: boolean | null;
|
|
15
|
+
}
|
|
16
|
+
| Array<{
|
|
17
|
+
id: string;
|
|
18
|
+
name: string | null;
|
|
19
|
+
personal: boolean | null;
|
|
20
|
+
}>
|
|
21
|
+
| null;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
type WorkspaceMemberRow = {
|
|
25
|
+
ws_id: string | null;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type MiraWorkspaceSummary = {
|
|
29
|
+
id: string;
|
|
30
|
+
name: string;
|
|
31
|
+
personal: boolean;
|
|
32
|
+
memberCount: number;
|
|
33
|
+
};
|
|
34
|
+
|
|
35
|
+
export type MiraWorkspaceContextState = {
|
|
36
|
+
workspaceContextId: string;
|
|
37
|
+
wsId: string;
|
|
38
|
+
name: string;
|
|
39
|
+
personal: boolean;
|
|
40
|
+
memberCount: number;
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
export function getWorkspaceContextWorkspaceId(ctx: MiraToolContext): string {
|
|
44
|
+
return ctx.workspaceContext?.wsId ?? ctx.wsId;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function toWorkspaceContextState(
|
|
48
|
+
workspace: MiraWorkspaceSummary,
|
|
49
|
+
workspaceContextId: string
|
|
50
|
+
): MiraWorkspaceContextState {
|
|
51
|
+
return {
|
|
52
|
+
workspaceContextId,
|
|
53
|
+
wsId: workspace.id,
|
|
54
|
+
name: workspace.name,
|
|
55
|
+
personal: workspace.personal,
|
|
56
|
+
memberCount: workspace.memberCount,
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export async function listAccessibleWorkspaceSummaries(
|
|
61
|
+
supabase: TypedSupabaseClient,
|
|
62
|
+
userId: string
|
|
63
|
+
): Promise<MiraWorkspaceSummary[]> {
|
|
64
|
+
const { data: membershipData, error: membershipError } = await supabase
|
|
65
|
+
.from('workspace_members')
|
|
66
|
+
.select(
|
|
67
|
+
`
|
|
68
|
+
ws_id,
|
|
69
|
+
workspaces!inner (
|
|
70
|
+
id,
|
|
71
|
+
name,
|
|
72
|
+
personal
|
|
73
|
+
)
|
|
74
|
+
`
|
|
75
|
+
)
|
|
76
|
+
.eq('user_id', userId);
|
|
77
|
+
|
|
78
|
+
if (membershipError) {
|
|
79
|
+
throw new Error(membershipError.message);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const memberships = (membershipData ?? []) as WorkspaceMembershipRow[];
|
|
83
|
+
const workspaceMap = new Map<
|
|
84
|
+
string,
|
|
85
|
+
Omit<MiraWorkspaceSummary, 'memberCount'>
|
|
86
|
+
>();
|
|
87
|
+
|
|
88
|
+
for (const membership of memberships) {
|
|
89
|
+
const workspace = Array.isArray(membership.workspaces)
|
|
90
|
+
? (membership.workspaces[0] ?? null)
|
|
91
|
+
: membership.workspaces;
|
|
92
|
+
|
|
93
|
+
if (!membership.ws_id || !workspace?.id) continue;
|
|
94
|
+
|
|
95
|
+
workspaceMap.set(workspace.id, {
|
|
96
|
+
id: workspace.id,
|
|
97
|
+
name: workspace.name?.trim() || 'Untitled workspace',
|
|
98
|
+
personal: workspace.personal === true,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const workspaceIds = [...workspaceMap.keys()];
|
|
103
|
+
if (workspaceIds.length === 0) {
|
|
104
|
+
return [];
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
const { data: memberRows, error: memberError } = await supabase
|
|
108
|
+
.from('workspace_members')
|
|
109
|
+
.select('ws_id')
|
|
110
|
+
.in('ws_id', workspaceIds);
|
|
111
|
+
|
|
112
|
+
if (memberError) {
|
|
113
|
+
throw new Error(memberError.message);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const memberCounts = new Map<string, number>();
|
|
117
|
+
for (const row of (memberRows ?? []) as WorkspaceMemberRow[]) {
|
|
118
|
+
if (!row.ws_id) continue;
|
|
119
|
+
memberCounts.set(row.ws_id, (memberCounts.get(row.ws_id) ?? 0) + 1);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
return workspaceIds
|
|
123
|
+
.map((id) => {
|
|
124
|
+
const workspace = workspaceMap.get(id)!;
|
|
125
|
+
return {
|
|
126
|
+
...workspace,
|
|
127
|
+
memberCount: memberCounts.get(id) ?? 0,
|
|
128
|
+
};
|
|
129
|
+
})
|
|
130
|
+
.sort((a, b) => {
|
|
131
|
+
if (a.personal !== b.personal) {
|
|
132
|
+
return a.personal ? -1 : 1;
|
|
133
|
+
}
|
|
134
|
+
return a.name.localeCompare(b.name);
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
type ResolveWorkspaceContextParams = {
|
|
139
|
+
supabase: TypedSupabaseClient;
|
|
140
|
+
userId: string;
|
|
141
|
+
requestedWorkspaceContextId?: string;
|
|
142
|
+
fallbackWorkspaceId?: string;
|
|
143
|
+
strict?: boolean;
|
|
144
|
+
};
|
|
145
|
+
|
|
146
|
+
export async function resolveWorkspaceContextState({
|
|
147
|
+
supabase,
|
|
148
|
+
userId,
|
|
149
|
+
requestedWorkspaceContextId,
|
|
150
|
+
fallbackWorkspaceId,
|
|
151
|
+
strict = false,
|
|
152
|
+
}: ResolveWorkspaceContextParams): Promise<MiraWorkspaceContextState> {
|
|
153
|
+
const accessibleWorkspaces = await listAccessibleWorkspaceSummaries(
|
|
154
|
+
supabase,
|
|
155
|
+
userId
|
|
156
|
+
);
|
|
157
|
+
|
|
158
|
+
if (accessibleWorkspaces.length === 0) {
|
|
159
|
+
throw new Error('No accessible workspaces found for current user.');
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
const personalWorkspace =
|
|
163
|
+
accessibleWorkspaces.find((workspace) => workspace.personal) ?? null;
|
|
164
|
+
const fallbackWorkspace =
|
|
165
|
+
(fallbackWorkspaceId
|
|
166
|
+
? accessibleWorkspaces.find(
|
|
167
|
+
(workspace) => workspace.id === fallbackWorkspaceId
|
|
168
|
+
)
|
|
169
|
+
: null) ??
|
|
170
|
+
personalWorkspace ??
|
|
171
|
+
accessibleWorkspaces[0]!;
|
|
172
|
+
|
|
173
|
+
const requested = normalizeWorkspaceContextId(requestedWorkspaceContextId);
|
|
174
|
+
const requestedRaw = requestedWorkspaceContextId?.trim();
|
|
175
|
+
if (!requestedRaw) {
|
|
176
|
+
return toWorkspaceContextState(
|
|
177
|
+
fallbackWorkspace,
|
|
178
|
+
fallbackWorkspace.personal
|
|
179
|
+
? PERSONAL_WORKSPACE_SLUG
|
|
180
|
+
: fallbackWorkspace.id
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (requested === PERSONAL_WORKSPACE_SLUG) {
|
|
185
|
+
if (!personalWorkspace) {
|
|
186
|
+
if (strict) {
|
|
187
|
+
throw new Error('Personal workspace is not available for this user.');
|
|
188
|
+
}
|
|
189
|
+
return toWorkspaceContextState(
|
|
190
|
+
fallbackWorkspace,
|
|
191
|
+
fallbackWorkspace.personal
|
|
192
|
+
? PERSONAL_WORKSPACE_SLUG
|
|
193
|
+
: fallbackWorkspace.id
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
return toWorkspaceContextState(personalWorkspace, PERSONAL_WORKSPACE_SLUG);
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const directMatch = accessibleWorkspaces.find(
|
|
201
|
+
(workspace) => workspace.id === requested
|
|
202
|
+
);
|
|
203
|
+
if (directMatch) {
|
|
204
|
+
return toWorkspaceContextState(
|
|
205
|
+
directMatch,
|
|
206
|
+
directMatch.personal ? PERSONAL_WORKSPACE_SLUG : directMatch.id
|
|
207
|
+
);
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
const nameMatches = accessibleWorkspaces.filter(
|
|
211
|
+
(workspace) =>
|
|
212
|
+
workspace.name.toLowerCase() === (requestedRaw ?? '').toLowerCase()
|
|
213
|
+
);
|
|
214
|
+
if (nameMatches.length === 1) {
|
|
215
|
+
return toWorkspaceContextState(
|
|
216
|
+
nameMatches[0]!,
|
|
217
|
+
nameMatches[0]!.personal ? PERSONAL_WORKSPACE_SLUG : nameMatches[0]!.id
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (strict) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
nameMatches.length > 1
|
|
224
|
+
? `Workspace name "${requested}" is ambiguous. Use the workspace ID instead.`
|
|
225
|
+
: `Workspace "${requestedRaw}" is not accessible for this user.`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return toWorkspaceContextState(
|
|
230
|
+
fallbackWorkspace,
|
|
231
|
+
fallbackWorkspace.personal ? PERSONAL_WORKSPACE_SLUG : fallbackWorkspace.id
|
|
232
|
+
);
|
|
233
|
+
}
|