@k2b/cloud 0.25.0 → 0.27.0
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/package.json +3 -3
- package/src/_internal/capabilities.ts +12 -0
- package/src/_internal/define-app.ts +8 -1
- package/src/_internal/process-identity.ts +7 -1
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/registry.ts +1 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/access/GroupCoverage.tsx +175 -0
- package/src/access/PermissionEditor.tsx +119 -99
- package/src/access/messages.ts +30 -0
- package/src/ai/admin.ts +1 -0
- package/src/ai/approval-routes.ts +5 -5
- package/src/ai/browser-code-contracts.ts +14 -2
- package/src/ai/browser.ts +8 -1
- package/src/ai/capabilities.ts +115 -33
- package/src/ai/chat/blocks.tsx +86 -179
- package/src/ai/chat/builtin-tools.tsx +83 -48
- package/src/ai/chat/file-tools.tsx +4 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +44 -0
- package/src/ai/chat/message-actions.tsx +6 -2
- package/src/ai/chat/message-utils.ts +17 -14
- package/src/ai/chat/messages.ts +330 -2
- package/src/ai/chat/presentation.tsx +278 -104
- package/src/ai/chat/tool-groups.ts +55 -35
- package/src/ai/chat/turn-layout.ts +141 -0
- package/src/ai/chat/turn-view.tsx +644 -0
- package/src/ai/client/controller.ts +60 -31
- package/src/ai/client/file-source.ts +20 -3
- package/src/ai/client/projection.ts +42 -6
- package/src/ai/code-mode-skill.ts +27 -27
- package/src/ai/code-runtime-tools.ts +10 -1
- package/src/ai/code-source-contracts.ts +54 -4
- package/src/ai/code-source-tools.ts +10 -3
- package/src/ai/credentials.ts +17 -3
- package/src/ai/data-analysis-skill.ts +2 -2
- package/src/ai/default-tools.ts +2 -2
- package/src/ai/executor.ts +177 -80
- package/src/ai/file-context.ts +14 -2
- package/src/ai/file-tools.ts +17 -3
- package/src/ai/files-store.ts +134 -11
- package/src/ai/grids-skill.ts +2 -2
- package/src/ai/index.ts +7 -0
- package/src/ai/memories.ts +14 -0
- package/src/ai/migrate.ts +125 -0
- package/src/ai/model-request-settings.ts +98 -0
- package/src/ai/protocol.ts +26 -4
- package/src/ai/provider-fetch.ts +67 -15
- package/src/ai/provider-retry.ts +105 -0
- package/src/ai/provider.ts +7 -1
- package/src/ai/quota-provider.ts +16 -7
- package/src/ai/request-headers.ts +117 -0
- package/src/ai/routes.ts +34 -6
- package/src/ai/runtime.ts +1 -1
- package/src/ai/settings.ts +19 -2
- package/src/ai/skill-seeds.ts +31 -3
- package/src/ai/skills.ts +26 -0
- package/src/ai/solid.ts +1 -1
- package/src/ai/store.ts +202 -59
- package/src/ai/stream.ts +182 -37
- package/src/ai/structured.ts +20 -5
- package/src/ai/system-prompt.ts +25 -0
- package/src/ai/timeline.ts +9 -11
- package/src/ai/tool-call-names.ts +45 -0
- package/src/ai/turn-policy.ts +247 -0
- package/src/ai/turn-timing.ts +31 -3
- package/src/ai/types.ts +36 -5
- package/src/api/admin-ai-quotas.ts +36 -1
- package/src/api/admin-core-settings.ts +16 -23
- package/src/api/admin-outgoing-mail.ts +62 -0
- package/src/api/index.ts +2 -0
- package/src/cli/admin/ai-quotas.ts +70 -1
- package/src/cli/admin/index.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +118 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/outgoing-mail.ts +77 -0
- package/src/contracts/registry.ts +4 -0
- package/src/services/index.ts +3 -0
- package/src/services/notifications/email.ts +16 -26
- package/src/services/outgoing-mail/index.ts +19 -0
- package/src/services/outgoing-mail/store.ts +286 -0
- package/src/services/outgoing-mail/test-send.ts +40 -0
- package/src/services/outgoing-mail/transport.ts +13 -0
- package/src/services/settings/core-settings.ts +1 -38
- package/src/services/settings/store.ts +5 -1
- package/src/shared/ai-model-request-settings.ts +21 -0
- package/src/shared/ai-platform-prompt.ts +1 -1
- package/src/shared/ai-request-options.ts +185 -0
- package/src/shared/app-presentation.ts +10 -2
- package/src/ssr/admin-navigation.ts +1 -1
- package/src/ssr/platform-messages.ts +2 -0
- package/src/ssr/workspace-navigation.ts +7 -1
- package/src/styles/effects.css +69 -0
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
import type { Provider, Tool, ToolResolver } from "@k2b/nessi";
|
|
2
|
+
import type { AiToolBlockStatus } from "./protocol";
|
|
3
|
+
|
|
4
|
+
/** Calls that only find or load other tools. */
|
|
5
|
+
const DISCOVERY_TOOL_NAMES = new Set(["search_tools", "list_apps", "load_tools"]);
|
|
6
|
+
/** Discovery calls in a row, without a completed working step, that earn a hint: three app lookups of search plus load. */
|
|
7
|
+
const DISCOVERY_STREAK_LIMIT = 6;
|
|
8
|
+
/** The last tenth of a turn's run time limit is kept for the answer. */
|
|
9
|
+
const FINAL_ANSWER_BUDGET_SHARE = 0.1;
|
|
10
|
+
|
|
11
|
+
export type AiTurnFinalReason = "tool_rounds" | "run_time" | "loop";
|
|
12
|
+
export type AiTurnHint = "repeated_failure" | "discovery_loop";
|
|
13
|
+
export type AiTurnPolicyDecision = { kind: "hint"; hints: AiTurnHint[] } | { kind: "final_answer"; reason: AiTurnFinalReason };
|
|
14
|
+
|
|
15
|
+
/** A finished tool call under the name the model called it by. */
|
|
16
|
+
export type AiTurnPolicyToolCall = { name: string; args?: unknown; status: AiToolBlockStatus; result?: unknown };
|
|
17
|
+
|
|
18
|
+
const FINAL_ANSWER_CAUSE: Record<AiTurnFinalReason, string> = {
|
|
19
|
+
tool_rounds: "The configured tool-round budget has been reached, so no more tools are available in this turn.",
|
|
20
|
+
run_time: "The run time limit of this turn is almost reached, so no more tools are available in this turn.",
|
|
21
|
+
loop: "This turn kept repeating steps that made no progress, so no more tools are available in this turn.",
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
const FINAL_ANSWER_CLOSE: Record<AiTurnFinalReason, string> = {
|
|
25
|
+
tool_rounds: "",
|
|
26
|
+
run_time: " Say which parts are still open; the user can continue the task with a new message.",
|
|
27
|
+
loop: " Say what blocked you and what the user can do about it.",
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const finalAnswerPrompt = (reason: AiTurnFinalReason) =>
|
|
31
|
+
`# Final response
|
|
32
|
+
${FINAL_ANSWER_CAUSE[reason]} Answer the user's request now with the best result supported by the evidence already gathered. State any material uncertainty or incomplete part clearly.${FINAL_ANSWER_CLOSE[reason]}`;
|
|
33
|
+
|
|
34
|
+
const listFormat = new Intl.ListFormat("en", { type: "conjunction" });
|
|
35
|
+
|
|
36
|
+
/** Names a hint may repeat: only tools the model was offered, never a name it made up. */
|
|
37
|
+
const offeredNames = (names: readonly string[], offered: ReadonlySet<string>) => {
|
|
38
|
+
const known = names.filter((name) => offered.has(name));
|
|
39
|
+
return listFormat.format(known.length < names.length ? [...known, "a tool that is not available"] : known);
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const hintPrompt = (hints: { repeatedFailures: string[]; discoveryStreak: number | null }, offered: ReadonlySet<string>) =>
|
|
43
|
+
[
|
|
44
|
+
"# Turn check",
|
|
45
|
+
hints.repeatedFailures.length > 0
|
|
46
|
+
? `Calls to ${offeredNames(hints.repeatedFailures, offered)} failed twice with the same input. Do not repeat them with that input. Change your approach, or tell the user what blocks you.`
|
|
47
|
+
: undefined,
|
|
48
|
+
hints.discoveryStreak !== null
|
|
49
|
+
? `You searched for or loaded tools ${hints.discoveryStreak} times in a row without completing another step. Stop searching. Use the tools you already have, load the ones a search found, or tell the user what is not available in this turn.`
|
|
50
|
+
: undefined,
|
|
51
|
+
]
|
|
52
|
+
.filter(Boolean)
|
|
53
|
+
.join("\n");
|
|
54
|
+
|
|
55
|
+
/** A completed load_tools call that made at least one more tool callable. */
|
|
56
|
+
const loadedTools = ({ name, status, result }: AiTurnPolicyToolCall) =>
|
|
57
|
+
name === "load_tools" &&
|
|
58
|
+
status === "completed" &&
|
|
59
|
+
typeof result === "object" &&
|
|
60
|
+
result !== null &&
|
|
61
|
+
"loaded" in result &&
|
|
62
|
+
Array.isArray(result.loaded) &&
|
|
63
|
+
result.loaded.length > 0;
|
|
64
|
+
|
|
65
|
+
const canonicalJson = (value: unknown): string => {
|
|
66
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
67
|
+
if (value && typeof value === "object")
|
|
68
|
+
return `{${Object.entries(value)
|
|
69
|
+
.filter(([, item]) => item !== undefined)
|
|
70
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
71
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`)
|
|
72
|
+
.join(",")}}`;
|
|
73
|
+
return JSON.stringify(value) ?? "null";
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
const withPrompt = (systemPrompt: string | undefined, suffix: string | null) =>
|
|
77
|
+
suffix ? `${systemPrompt ?? ""}\n\n${suffix}`.trim() : systemPrompt;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Keeps a chat turn from ending without an answer (#530).
|
|
81
|
+
*
|
|
82
|
+
* The policy is checked before every model call:
|
|
83
|
+
* - the same call (tool name and input) failing twice, or six discovery calls
|
|
84
|
+
* in a row without a completed working step, adds a one-time hint to the next
|
|
85
|
+
* model call;
|
|
86
|
+
* - either pattern after its hint, a positive `maxToolRounds` that is used up,
|
|
87
|
+
* or the last tenth of the run time limit ends tool use: the next model call
|
|
88
|
+
* gets no tools and a prompt to answer with what the turn has. If that call
|
|
89
|
+
* still asks for tools, the turn fails instead of looping without them.
|
|
90
|
+
*
|
|
91
|
+
* Rejected approvals are user decisions, not failures, and loading tools a
|
|
92
|
+
* search found does not continue a discovery loop. A steering message starts
|
|
93
|
+
* the loop checks over. The deadline abort stays as the backstop when a single
|
|
94
|
+
* model or tool call outlasts the reserve. Calls a resumed attempt already
|
|
95
|
+
* finished seed the counts without a hint of their own; a hint the earlier
|
|
96
|
+
* attempt gave is not remembered.
|
|
97
|
+
*/
|
|
98
|
+
export const applyAiTurnPolicy = (input: {
|
|
99
|
+
provider: Provider;
|
|
100
|
+
tools: Tool[] | ToolResolver;
|
|
101
|
+
maxToolRounds?: number;
|
|
102
|
+
issuedToolRounds: number;
|
|
103
|
+
completedToolRounds: number;
|
|
104
|
+
/** Epoch milliseconds when the run time limit ends; null without a limit. */
|
|
105
|
+
deadline: number | null;
|
|
106
|
+
runBudgetMs: number | null;
|
|
107
|
+
/** Tool calls this turn finished before this attempt and after its last steering message, oldest first. */
|
|
108
|
+
finishedToolCalls?: readonly AiTurnPolicyToolCall[];
|
|
109
|
+
onDecision?: (decision: AiTurnPolicyDecision) => void;
|
|
110
|
+
now?: () => number;
|
|
111
|
+
}): {
|
|
112
|
+
provider: Provider;
|
|
113
|
+
tools: ToolResolver;
|
|
114
|
+
maxTurns?: number;
|
|
115
|
+
noteToolRound: () => void;
|
|
116
|
+
noteToolCall: (call: AiTurnPolicyToolCall) => void;
|
|
117
|
+
noteSteering: () => void;
|
|
118
|
+
} => {
|
|
119
|
+
const now = input.now ?? Date.now;
|
|
120
|
+
const limit = Math.floor(input.maxToolRounds ?? 0);
|
|
121
|
+
const issuedAtStart = Math.max(0, Math.floor(input.issuedToolRounds));
|
|
122
|
+
let completed = Math.max(0, Math.floor(input.completedToolRounds));
|
|
123
|
+
// nessi resolves the tools once more to finish a round an earlier attempt left open.
|
|
124
|
+
let resumingRound = issuedAtStart > completed;
|
|
125
|
+
const reserveMs = input.runBudgetMs && input.runBudgetMs > 0 ? input.runBudgetMs * FINAL_ANSWER_BUDGET_SHARE : 0;
|
|
126
|
+
|
|
127
|
+
const failures = new Map<string, number>();
|
|
128
|
+
let repeatedFailures = new Set<string>();
|
|
129
|
+
let discoveryStreak = 0;
|
|
130
|
+
// A discovery call since the last check that made no new tool callable.
|
|
131
|
+
let discoveredSinceCheck = false;
|
|
132
|
+
const hinted = new Set<AiTurnHint>();
|
|
133
|
+
let finalReason: AiTurnFinalReason | null = null;
|
|
134
|
+
let completedAtFinal = 0;
|
|
135
|
+
let pendingHint: string | null = null;
|
|
136
|
+
let offered = new Set<string>();
|
|
137
|
+
|
|
138
|
+
const noteToolCall = (call: AiTurnPolicyToolCall) => {
|
|
139
|
+
const { name, args, status } = call;
|
|
140
|
+
if (status !== "completed" && status !== "failed") return;
|
|
141
|
+
if (status === "failed") {
|
|
142
|
+
const key = `${name}\u0000${canonicalJson(args)}`;
|
|
143
|
+
const count = (failures.get(key) ?? 0) + 1;
|
|
144
|
+
failures.set(key, count);
|
|
145
|
+
if (count >= 2) repeatedFailures.add(name);
|
|
146
|
+
}
|
|
147
|
+
if (DISCOVERY_TOOL_NAMES.has(name)) {
|
|
148
|
+
discoveryStreak += 1;
|
|
149
|
+
if (!loadedTools(call)) discoveredSinceCheck = true;
|
|
150
|
+
} else if (status === "completed") {
|
|
151
|
+
discoveryStreak = 0;
|
|
152
|
+
}
|
|
153
|
+
};
|
|
154
|
+
for (const call of input.finishedToolCalls ?? []) noteToolCall(call);
|
|
155
|
+
repeatedFailures = new Set();
|
|
156
|
+
discoveredSinceCheck = false;
|
|
157
|
+
|
|
158
|
+
const reserveReached = () => input.deadline !== null && reserveMs > 0 && input.deadline - now() <= reserveMs;
|
|
159
|
+
const endToolUse = (reason: AiTurnFinalReason) => {
|
|
160
|
+
finalReason = reason;
|
|
161
|
+
completedAtFinal = completed;
|
|
162
|
+
pendingHint = null;
|
|
163
|
+
input.onDecision?.({ kind: "final_answer", reason });
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
const check = () => {
|
|
167
|
+
if (finalReason) return;
|
|
168
|
+
if (limit > 0 && completed >= limit) endToolUse("tool_rounds");
|
|
169
|
+
else if (reserveReached()) endToolUse("run_time");
|
|
170
|
+
else {
|
|
171
|
+
const triggered: AiTurnHint[] = [];
|
|
172
|
+
if (repeatedFailures.size > 0) triggered.push("repeated_failure");
|
|
173
|
+
if (discoveredSinceCheck && discoveryStreak >= DISCOVERY_STREAK_LIMIT) triggered.push("discovery_loop");
|
|
174
|
+
if (triggered.some((hint) => hinted.has(hint))) endToolUse("loop");
|
|
175
|
+
else if (triggered.length > 0) {
|
|
176
|
+
for (const hint of triggered) hinted.add(hint);
|
|
177
|
+
pendingHint = hintPrompt(
|
|
178
|
+
{
|
|
179
|
+
repeatedFailures: triggered.includes("repeated_failure") ? [...repeatedFailures] : [],
|
|
180
|
+
discoveryStreak: triggered.includes("discovery_loop") ? discoveryStreak : null,
|
|
181
|
+
},
|
|
182
|
+
offered,
|
|
183
|
+
);
|
|
184
|
+
input.onDecision?.({ kind: "hint", hints: triggered });
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
repeatedFailures = new Set();
|
|
188
|
+
discoveredSinceCheck = false;
|
|
189
|
+
};
|
|
190
|
+
|
|
191
|
+
const tools: ToolResolver = async () => {
|
|
192
|
+
if (resumingRound) resumingRound = false;
|
|
193
|
+
else check();
|
|
194
|
+
if (finalReason) {
|
|
195
|
+
if (completed > completedAtFinal) throw new Error("The model did not produce a final answer without tools.");
|
|
196
|
+
return [];
|
|
197
|
+
}
|
|
198
|
+
const resolved = typeof input.tools === "function" ? await input.tools() : input.tools;
|
|
199
|
+
offered = new Set(resolved.map((tool) => tool.def.name));
|
|
200
|
+
return resolved;
|
|
201
|
+
};
|
|
202
|
+
const provider: Provider = {
|
|
203
|
+
name: input.provider.name,
|
|
204
|
+
family: input.provider.family,
|
|
205
|
+
model: input.provider.model,
|
|
206
|
+
contextWindow: input.provider.contextWindow,
|
|
207
|
+
capabilities: input.provider.capabilities,
|
|
208
|
+
complete: (request) => input.provider.complete(request),
|
|
209
|
+
stream: async function* (request) {
|
|
210
|
+
// Compaction may run between the tool check and this call; the reserve still holds.
|
|
211
|
+
if (!finalReason && reserveReached()) endToolUse("run_time");
|
|
212
|
+
let overflowed = false;
|
|
213
|
+
for await (const event of input.provider.stream({
|
|
214
|
+
...request,
|
|
215
|
+
tools: finalReason ? [] : request.tools,
|
|
216
|
+
systemPrompt: withPrompt(request.systemPrompt, finalReason ? finalAnswerPrompt(finalReason) : pendingHint),
|
|
217
|
+
})) {
|
|
218
|
+
if (event.type === "issue" && event.issue.kind === "provider_error" && event.issue.contextOverflow) overflowed = true;
|
|
219
|
+
yield event;
|
|
220
|
+
}
|
|
221
|
+
// nessi compacts an overflowed request and sends it again; the hint goes with it.
|
|
222
|
+
if (!overflowed) pendingHint = null;
|
|
223
|
+
},
|
|
224
|
+
};
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
provider,
|
|
228
|
+
tools,
|
|
229
|
+
// Nessi checks this before provider calls. The extra round is the tool-free
|
|
230
|
+
// synthesis call after the last allowed tool-using round.
|
|
231
|
+
...(limit > 0 ? { maxTurns: Math.max(1, limit - issuedAtStart + 1) } : {}),
|
|
232
|
+
noteToolRound: () => {
|
|
233
|
+
completed += 1;
|
|
234
|
+
},
|
|
235
|
+
noteToolCall,
|
|
236
|
+
noteSteering: () => {
|
|
237
|
+
// New user input: the loop checks start over, and a loop's final answer may use tools again.
|
|
238
|
+
failures.clear();
|
|
239
|
+
repeatedFailures = new Set();
|
|
240
|
+
discoveryStreak = 0;
|
|
241
|
+
discoveredSinceCheck = false;
|
|
242
|
+
hinted.clear();
|
|
243
|
+
pendingHint = null;
|
|
244
|
+
finalReason = null;
|
|
245
|
+
},
|
|
246
|
+
};
|
|
247
|
+
};
|
package/src/ai/turn-timing.ts
CHANGED
|
@@ -80,6 +80,36 @@ export function createTurnTimingRecorder(turnId: string, db = sql) {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Time a turn waited for the person: approvals and answers until they were given. A tool the browser runs by itself
|
|
85
|
+
* waits only until it starts; a secret prompt waits for its answer even though the browser claimed it. An open wait
|
|
86
|
+
* ends now.
|
|
87
|
+
*/
|
|
88
|
+
const actionWaits = (turnId: string, db: typeof sql) =>
|
|
89
|
+
db<{ start: Date; end: Date; open: boolean }[]>`SELECT a.created_at AS start,
|
|
90
|
+
COALESCE(w.end, now()) AS end, w.end IS NULL AS open
|
|
91
|
+
FROM ai.pending_actions a LEFT JOIN ai.tool_calls t ON t.turn_id=a.turn_id AND t.call_id=a.call_id
|
|
92
|
+
CROSS JOIN LATERAL (SELECT CASE WHEN a.kind='client_tool' AND a.tool_name<>'code_secret' THEN LEAST(t.started_at,a.resolved_at)
|
|
93
|
+
ELSE a.resolved_at END AS end) w
|
|
94
|
+
WHERE a.turn_id=${turnId}::uuid`;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Waits of a running turn by the same rules as its durable timing: `actionWaitMs` is the time already waited, and
|
|
98
|
+
* `waitingSince` the start of the wait that is still open, overlapping waits counted once.
|
|
99
|
+
*/
|
|
100
|
+
export async function activeTurnWaits(turnId: string, db = sql): Promise<{ actionWaitMs: number; waitingSince: string | null }> {
|
|
101
|
+
const rows = await actionWaits(turnId, db);
|
|
102
|
+
const waits = union(rows.map((x) => ({ start: new Date(x.start).getTime(), end: new Date(x.end).getTime() })));
|
|
103
|
+
const opens = rows.filter((x) => x.open).map((x) => new Date(x.start).getTime());
|
|
104
|
+
const openStart = opens.length > 0 ? Math.min(...opens) : null;
|
|
105
|
+
// An open wait reaches until now, so the merged wait that holds it is the last one.
|
|
106
|
+
const current = openStart === null ? undefined : waits.find((x) => x.start <= openStart && openStart <= x.end);
|
|
107
|
+
return {
|
|
108
|
+
actionWaitMs: duration(waits.filter((x) => x !== current)),
|
|
109
|
+
waitingSince: current ? new Date(current.start).toISOString() : null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
83
113
|
export async function withDurableTurnTiming(turnId: string, aggregate: LoopAggregate, db = sql): Promise<LoopAggregate> {
|
|
84
114
|
const [turn] = await db<
|
|
85
115
|
{ start: Date; end: Date }[]
|
|
@@ -95,9 +125,7 @@ export async function withDurableTurnTiming(turnId: string, aggregate: LoopAggre
|
|
|
95
125
|
const tools = await db<
|
|
96
126
|
{ start: Date; end: Date }[]
|
|
97
127
|
>`SELECT started_at AS start,COALESCE(completed_at,now()) AS end FROM ai.tool_calls WHERE turn_id=${turnId}::uuid AND started_at IS NOT NULL`;
|
|
98
|
-
const waits = await db
|
|
99
|
-
CASE WHEN a.kind='client_tool' THEN LEAST(COALESCE(t.started_at,a.resolved_at,now()),COALESCE(a.resolved_at,now())) ELSE COALESCE(a.resolved_at,now()) END AS end
|
|
100
|
-
FROM ai.pending_actions a LEFT JOIN ai.tool_calls t ON t.turn_id=a.turn_id AND t.call_id=a.call_id WHERE a.turn_id=${turnId}::uuid`;
|
|
128
|
+
const waits = await actionWaits(turnId, db);
|
|
101
129
|
const intervals = (rows: { start: Date; end: Date | null }[]) =>
|
|
102
130
|
rows.flatMap((x) => (x.end ? [{ start: new Date(x.start).getTime(), end: new Date(x.end).getTime() }] : []));
|
|
103
131
|
return {
|
package/src/ai/types.ts
CHANGED
|
@@ -45,6 +45,8 @@ export type AiModelProfile = {
|
|
|
45
45
|
contextWindow?: number;
|
|
46
46
|
temperature?: number;
|
|
47
47
|
maxOutputTokens?: number;
|
|
48
|
+
reasoningEffort?: string;
|
|
49
|
+
extraBody?: Record<string, unknown>;
|
|
48
50
|
/** Maximum deferred tools retained per conversation. Missing or <= 0 keeps all loaded tools. */
|
|
49
51
|
maxLoadedTools?: number;
|
|
50
52
|
/** Tool-using model rounds per chat turn. Missing or <= 0 is unlimited. */
|
|
@@ -296,7 +298,7 @@ export type AiStoredMessage = {
|
|
|
296
298
|
trigger: "scheduled" | "manual";
|
|
297
299
|
};
|
|
298
300
|
toolPresentations?: Record<string, AiToolPresentation>;
|
|
299
|
-
toolOutcomes?: Record<string, "rejected">;
|
|
301
|
+
toolOutcomes?: Record<string, "rejected" | "approved">;
|
|
300
302
|
} | null;
|
|
301
303
|
/** Private owner feedback for this rendered assistant response. Never enters model context. */
|
|
302
304
|
feedback?: AiMessageFeedback | null;
|
|
@@ -510,7 +512,14 @@ export type AiConversationResourceOccurrence = AiConversationResourceRef & {
|
|
|
510
512
|
chat: Pick<AiConversation, "shortId" | "title" | "updatedAt">;
|
|
511
513
|
};
|
|
512
514
|
|
|
513
|
-
|
|
515
|
+
/**
|
|
516
|
+
* - `result`: something the assistant delivered with `present` (a file, `path` set), `code_open` (an app, `ref` set) or
|
|
517
|
+
* `code_present` (a chat visualization, neither set; `sourceCallId` names it).
|
|
518
|
+
* - `web`: a page read with `web_extract`; `activity`: a web search, one entry per query.
|
|
519
|
+
* - `resource`: a Cloud resource a tool call read, or one indexed as Project context without a call.
|
|
520
|
+
* - `file`: a conversation file.
|
|
521
|
+
*/
|
|
522
|
+
export type AiConversationSourceKind = "result" | "web" | "file" | "resource" | "activity";
|
|
514
523
|
|
|
515
524
|
export type AiConversationSource = {
|
|
516
525
|
kind: AiConversationSourceKind;
|
|
@@ -528,15 +537,23 @@ export type AiConversationSource = {
|
|
|
528
537
|
lastSeenAt: string;
|
|
529
538
|
sourceTurnId: string | null;
|
|
530
539
|
sourceCallId: string | null;
|
|
540
|
+
/**
|
|
541
|
+
* Chat position of the source: the assistant message that holds `sourceCallId`, otherwise the start of its turn.
|
|
542
|
+
* Use it as the `message` link target; null when the turn is gone.
|
|
543
|
+
*/
|
|
544
|
+
sourceMessageSeq: number | null;
|
|
531
545
|
};
|
|
532
546
|
|
|
533
547
|
export type AiConversationSourceObservation = {
|
|
534
|
-
kind: "web" | "activity";
|
|
548
|
+
kind: "result" | "web" | "activity";
|
|
535
549
|
key: string;
|
|
536
550
|
title: string;
|
|
551
|
+
/** For `result`, the delivered description; a new delivery always replaces it, also with none. */
|
|
537
552
|
preview?: string;
|
|
538
553
|
icon?: string;
|
|
539
554
|
href?: string;
|
|
555
|
+
/** The delivered Cloud resource of a `result`, for example an opened app. */
|
|
556
|
+
ref?: CloudResourceRef;
|
|
540
557
|
};
|
|
541
558
|
|
|
542
559
|
export type AiConversationFileSnapshotEntry = {
|
|
@@ -707,7 +724,11 @@ export type AiConversationService = {
|
|
|
707
724
|
search?: string;
|
|
708
725
|
before?: string;
|
|
709
726
|
limit?: number;
|
|
710
|
-
|
|
727
|
+
/** Only these kinds; all kinds when omitted. */
|
|
728
|
+
kinds?: readonly AiConversationSourceKind[];
|
|
729
|
+
/** Only resources a tool call read; leaves out resources indexed as Project context without a call. */
|
|
730
|
+
observed?: boolean;
|
|
731
|
+
}): Promise<{ sources: AiConversationSource[]; nextCursor?: string; total: number }>;
|
|
711
732
|
getCapabilityInvocationOrigin(input: { idempotencyKey: string; toolName: string }): Promise<{
|
|
712
733
|
conversationId: string;
|
|
713
734
|
conversationShortId: string;
|
|
@@ -864,7 +885,15 @@ export type AiConversationService = {
|
|
|
864
885
|
getLatestTurn(input: { conversationId: string }): Promise<AiTurn | null>;
|
|
865
886
|
getTurn(input: { conversationId: string; turnId: string }): Promise<AiTurn | null>;
|
|
866
887
|
getTurnByShortId(input: { conversationId: string; shortId: string }): Promise<AiTurn | null>;
|
|
867
|
-
getActiveTurn(input: { conversationId: string }): Promise<{
|
|
888
|
+
getActiveTurn(input: { conversationId: string }): Promise<{
|
|
889
|
+
turn: AiTurn;
|
|
890
|
+
liveBlocks: AiTurnBlock[];
|
|
891
|
+
liveSeq: number;
|
|
892
|
+
/** Time spent waiting for answered user actions. */
|
|
893
|
+
actionWaitMs: number;
|
|
894
|
+
/** Start of the oldest unanswered user action, if the turn waits now. */
|
|
895
|
+
waitingSince: string | null;
|
|
896
|
+
} | null>;
|
|
868
897
|
/**
|
|
869
898
|
* Claim a turn attempt. Increments attempt and takes the lease atomically.
|
|
870
899
|
* `from: "queue"` claims queued or lease-expired running turns; `from: "waiting"`
|
|
@@ -944,6 +973,8 @@ export type AiConversationService = {
|
|
|
944
973
|
toolPresentations?: ReadonlyMap<string, AiToolPresentation>;
|
|
945
974
|
/** Mutable call-id set read only when a rejected tool result is persisted. */
|
|
946
975
|
rejectedToolCallIds?: ReadonlySet<string>;
|
|
976
|
+
/** Mutable call-id set read only when the result of a call the user approved is persisted. */
|
|
977
|
+
approvedToolCallIds?: ReadonlySet<string>;
|
|
947
978
|
}): SessionStore;
|
|
948
979
|
};
|
|
949
980
|
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { Hono, type MiddlewareHandler } from "hono";
|
|
2
|
+
import { describeRoute } from "hono-openapi";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { AiBackgroundCostError, backgroundCostState, releaseBackgroundCostStop } from "../ai/inference-calls";
|
|
4
5
|
import { setAiModelPricing } from "../ai/model-pricing";
|
|
6
|
+
import { AiModelRequestSettingsInvalid, getAiModelRequestSettings, setAiModelRequestSettings } from "../ai/model-request-settings";
|
|
5
7
|
import { quotaAdminConfig, quotaReport } from "../ai/quota-report";
|
|
6
8
|
import { AiQuotaError, aiQuotas } from "../ai/quotas";
|
|
7
9
|
import { readAiSettingsState } from "../ai/settings";
|
|
8
|
-
import { type AuthContext, auth, v } from "../server";
|
|
10
|
+
import { type AuthContext, auth, jsonResponse, requiresAdmin, v } from "../server";
|
|
9
11
|
import { AiModelPricingSchema, hasBillableAiPricing } from "../shared/ai-costs";
|
|
12
|
+
import { AiModelRequestSettingsSchema, AiModelRequestSettingsUpdateSchema } from "../shared/ai-model-request-settings";
|
|
10
13
|
import {
|
|
11
14
|
AiQuotaConfigSchema,
|
|
12
15
|
AiQuotaIdentitySchema,
|
|
@@ -21,6 +24,7 @@ export const createAdminAiQuotaRoutes = (authenticate: MiddlewareHandler<AuthCon
|
|
|
21
24
|
new Hono<AuthContext>()
|
|
22
25
|
.use("*", authenticate)
|
|
23
26
|
.onError((error, c) => {
|
|
27
|
+
if (error instanceof AiModelRequestSettingsInvalid) return c.json({ message: error.message }, 400);
|
|
24
28
|
if (error instanceof AiQuotaError || error instanceof AiBackgroundCostError)
|
|
25
29
|
return c.json({ error: error.code, message: error.message }, 409);
|
|
26
30
|
throw error;
|
|
@@ -46,6 +50,37 @@ export const createAdminAiQuotaRoutes = (authenticate: MiddlewareHandler<AuthCon
|
|
|
46
50
|
return c.json(await setAiModelPricing(c.req.param("id")!, data.pricing, data.expected, c.get("user")!.id));
|
|
47
51
|
},
|
|
48
52
|
)
|
|
53
|
+
.get(
|
|
54
|
+
"/models/:id/settings",
|
|
55
|
+
describeRoute({
|
|
56
|
+
tags: ["Administration"],
|
|
57
|
+
summary: "Read masked model request settings",
|
|
58
|
+
...requiresAdmin,
|
|
59
|
+
responses: { 200: jsonResponse(AiModelRequestSettingsSchema, "Model request settings (header names only)") },
|
|
60
|
+
}),
|
|
61
|
+
async (c) => {
|
|
62
|
+
c.header("Cache-Control", "no-store");
|
|
63
|
+
return c.json(await getAiModelRequestSettings(c.req.param("id")!));
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
.put(
|
|
67
|
+
"/models/:id/settings",
|
|
68
|
+
describeRoute({
|
|
69
|
+
tags: ["Administration"],
|
|
70
|
+
summary: "Patch model request settings with a revision guard",
|
|
71
|
+
...requiresAdmin,
|
|
72
|
+
responses: {
|
|
73
|
+
200: jsonResponse(AiModelRequestSettingsSchema, "Updated model request settings (header names only)"),
|
|
74
|
+
400: jsonResponse(z.object({ message: z.string() }), "Invalid model request settings"),
|
|
75
|
+
409: jsonResponse(z.object({ message: z.string(), error: z.string() }), "Model request settings changed"),
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
v("json", AiModelRequestSettingsUpdateSchema),
|
|
79
|
+
async (c) => {
|
|
80
|
+
c.header("Cache-Control", "no-store");
|
|
81
|
+
return c.json(await setAiModelRequestSettings(c.req.param("id")!, c.req.valid("json"), c.get("user")!.id));
|
|
82
|
+
},
|
|
83
|
+
)
|
|
49
84
|
.get("/background", async (c) => c.json(await backgroundCostState()))
|
|
50
85
|
.post("/background/release", async (c) => c.json(await releaseBackgroundCostStop(c.get("user")!.id)))
|
|
51
86
|
.get("/report", v("query", AiQuotaReportQuerySchema), async (c) => c.json(await quotaReport(c.req.valid("query"))))
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
aiModelAccess,
|
|
20
20
|
splitAiModelAccess,
|
|
21
21
|
} from "../ai/model-access";
|
|
22
|
+
import { listAiRequestHeaderNames, planAiProfileRequestHeaders, storeAiRequestHeaderPlan } from "../ai/request-headers";
|
|
22
23
|
import { parseAiModelProfiles, planAiProfileCredentials, validateAiSettingsConfiguration } from "../ai/settings";
|
|
23
24
|
import { type AiSettingsIssueWithMessage, aiSettingsNotSavedMessage, describeAiSettingsIssues } from "../ai/settings-messages";
|
|
24
25
|
import { type AuthContext, auth, getLocale, jsonResponse, requiresAdmin, v } from "../server";
|
|
@@ -27,7 +28,6 @@ import { readAccountCategoryPolicy } from "../services/account-category-policy";
|
|
|
27
28
|
import { audit } from "../services/audit";
|
|
28
29
|
import { validateFreeIpaCaCert } from "../services/freeipa-config";
|
|
29
30
|
import { testFreeIpaConnection } from "../services/ipa/connection";
|
|
30
|
-
import { sendEmail } from "../services/notifications/email";
|
|
31
31
|
import { GotenbergRenderError, testGotenberg } from "../services/pdf";
|
|
32
32
|
import * as settings from "../services/settings";
|
|
33
33
|
import { SETTINGS_MAP, validateSettingValue } from "../services/settings/defaults";
|
|
@@ -43,10 +43,6 @@ const BulkSaveSchema = z.union([
|
|
|
43
43
|
.strict(),
|
|
44
44
|
z.record(z.string(), z.unknown()).transform((updates) => ({ updates, resets: [] as string[] })),
|
|
45
45
|
]);
|
|
46
|
-
const TestEmailSchema = z.object({
|
|
47
|
-
recipient: z.email(),
|
|
48
|
-
});
|
|
49
|
-
|
|
50
46
|
const LEGAL_DOCUMENTS = [
|
|
51
47
|
{ kind: "terms", path: "/legal/terms" },
|
|
52
48
|
{ kind: "privacy", path: "/legal/privacy" },
|
|
@@ -101,6 +97,7 @@ type AiSettingsMutationPlan = {
|
|
|
101
97
|
/** Structured validation issues with stable codes; `errors` carries the same messages keyed by setting. */
|
|
102
98
|
issues?: AiSettingsIssueWithMessage[];
|
|
103
99
|
keepCredentialProfileIds?: string[];
|
|
100
|
+
headerPlan?: ReturnType<typeof planAiProfileRequestHeaders>;
|
|
104
101
|
modelProfileIds?: string[];
|
|
105
102
|
};
|
|
106
103
|
|
|
@@ -168,6 +165,17 @@ const prepareAiSettingsMutation = async (
|
|
|
168
165
|
keepCredentialProfileIds = credentialPlan.keepCredentialProfileIds;
|
|
169
166
|
}
|
|
170
167
|
|
|
168
|
+
const headerPlan =
|
|
169
|
+
profilesUpdated || profilesReset
|
|
170
|
+
? planAiProfileRequestHeaders({
|
|
171
|
+
currentProfiles: currentParsed.profiles,
|
|
172
|
+
nextProfiles: nextParsed.profiles,
|
|
173
|
+
existingNames: profilesUpdated ? await listAiRequestHeaderNames() : {},
|
|
174
|
+
submitted: aiSplit?.requestHeaders ?? [],
|
|
175
|
+
})
|
|
176
|
+
: undefined;
|
|
177
|
+
if (headerPlan?.error) return { errors: { [AI_PROFILES_KEY]: headerPlan.error } };
|
|
178
|
+
|
|
171
179
|
const issues = validateAiSettingsConfiguration({
|
|
172
180
|
enabled: nextEnabled,
|
|
173
181
|
defaultModelId: String(valueAfterMutation(AI_DEFAULT_MODEL_KEY, currentDefaultModelId ?? "", updates, resets)),
|
|
@@ -183,6 +191,7 @@ const prepareAiSettingsMutation = async (
|
|
|
183
191
|
return {
|
|
184
192
|
errors: described.errors,
|
|
185
193
|
issues: described.issues,
|
|
194
|
+
headerPlan,
|
|
186
195
|
keepCredentialProfileIds,
|
|
187
196
|
modelProfileIds: profilesUpdated || profilesReset ? nextParsed.profiles.map((profile) => profile.id) : undefined,
|
|
188
197
|
};
|
|
@@ -294,24 +303,6 @@ const app = new Hono<AuthContext>()
|
|
|
294
303
|
.delete("/legacy", auth.requireRole("admin"), async (c) => {
|
|
295
304
|
return c.json(await settingsDeleteLegacyKeys(await liveSettingKeys()));
|
|
296
305
|
})
|
|
297
|
-
.post("/test-email", auth.requireRole("admin"), v("json", TestEmailSchema), async (c) => {
|
|
298
|
-
const { recipient } = c.req.valid("json");
|
|
299
|
-
const sentAt = new Date().toISOString();
|
|
300
|
-
|
|
301
|
-
try {
|
|
302
|
-
await sendEmail(recipient, "Cloud test email", {
|
|
303
|
-
rawHtml: `
|
|
304
|
-
<p>This is a test email from Cloud.</p>
|
|
305
|
-
<p>If you received this message, SMTP delivery is configured correctly.</p>
|
|
306
|
-
<p style="margin-top:24px;color:#71717a;font-size:12px;">Sent at ${sentAt}</p>
|
|
307
|
-
`,
|
|
308
|
-
});
|
|
309
|
-
return c.json({ ok: true });
|
|
310
|
-
} catch (error) {
|
|
311
|
-
const message = error instanceof Error ? error.message : "Failed to send test email";
|
|
312
|
-
return c.json({ message }, 500);
|
|
313
|
-
}
|
|
314
|
-
})
|
|
315
306
|
.post("/run-ai-enrichment", auth.requireRole("admin"), async (c) => {
|
|
316
307
|
try {
|
|
317
308
|
// Small manual batch — the cron handles bulk; this is "kick it now".
|
|
@@ -463,6 +454,7 @@ const app = new Hono<AuthContext>()
|
|
|
463
454
|
} else if (aiPlan.keepCredentialProfileIds) {
|
|
464
455
|
await pruneAiCredentials(aiPlan.keepCredentialProfileIds, tx);
|
|
465
456
|
}
|
|
457
|
+
if (aiPlan.headerPlan) await storeAiRequestHeaderPlan(aiPlan.headerPlan, tx);
|
|
466
458
|
if (aiPlan.modelProfileIds) {
|
|
467
459
|
await aiModelAccess.syncProfiles(aiPlan.modelProfileIds, accessChanges, tx);
|
|
468
460
|
}
|
|
@@ -524,6 +516,7 @@ const app = new Hono<AuthContext>()
|
|
|
524
516
|
if (aiPlan.keepCredentialProfileIds) {
|
|
525
517
|
await pruneAiCredentials(aiPlan.keepCredentialProfileIds, tx);
|
|
526
518
|
}
|
|
519
|
+
if (aiPlan.headerPlan) await storeAiRequestHeaderPlan(aiPlan.headerPlan, tx);
|
|
527
520
|
if (aiPlan.modelProfileIds) {
|
|
528
521
|
await aiModelAccess.syncProfiles(aiPlan.modelProfileIds, [], tx);
|
|
529
522
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { type Context, Hono, type MiddlewareHandler } from "hono";
|
|
2
|
+
import { validator } from "hono-openapi";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { MailAppAccessSchema, MailProfileInputSchema, MailProfileKeySchema } from "../contracts/outgoing-mail";
|
|
5
|
+
import { type AuthContext, auth } from "../server";
|
|
6
|
+
import { type MailAuditContext, OutgoingMailError, outgoingMailStore } from "../services/outgoing-mail/store";
|
|
7
|
+
import { outgoingMailTest } from "../services/outgoing-mail/test-send";
|
|
8
|
+
|
|
9
|
+
const validate = <Target extends "param" | "json", Schema extends z.ZodType>(target: Target, schema: Schema) =>
|
|
10
|
+
validator(target, schema, (result, c) => {
|
|
11
|
+
if (!result.success) {
|
|
12
|
+
const issue = result.error?.[0];
|
|
13
|
+
const path = issue?.path?.map((part) => (typeof part === "object" && "key" in part ? String(part.key) : String(part))).join(".");
|
|
14
|
+
return c.json(
|
|
15
|
+
{
|
|
16
|
+
code: "invalid_profile" as const,
|
|
17
|
+
message: issue ? `${path ? `${path}: ` : ""}${issue.message}` : "Invalid outgoing mail configuration.",
|
|
18
|
+
},
|
|
19
|
+
400,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
});
|
|
23
|
+
const context = (c: Context<AuthContext>): MailAuditContext => ({
|
|
24
|
+
actor: { userId: c.get("user").id, uid: c.get("user").uid, provider: c.get("user").provider, roles: c.get("user").roles },
|
|
25
|
+
requestId: c.req.header("x-request-id"),
|
|
26
|
+
});
|
|
27
|
+
export const createAdminOutgoingMailRoutes = (authenticate: MiddlewareHandler<AuthContext> = auth.requireRole("admin")) =>
|
|
28
|
+
new Hono<AuthContext>()
|
|
29
|
+
.use("*", authenticate)
|
|
30
|
+
.use("*", async (c, next) => {
|
|
31
|
+
c.header("Cache-Control", "no-store");
|
|
32
|
+
await next();
|
|
33
|
+
})
|
|
34
|
+
.onError((error, c) => {
|
|
35
|
+
if (error instanceof OutgoingMailError) return c.json({ error: error.code, code: error.code, message: error.message }, error.status);
|
|
36
|
+
throw error;
|
|
37
|
+
})
|
|
38
|
+
.get("/profiles", async (c) => c.json({ items: await outgoingMailStore.list() }))
|
|
39
|
+
.get("/profiles/:key", async (c) => c.json(await outgoingMailStore.get(c.req.param("key"))))
|
|
40
|
+
.put(
|
|
41
|
+
"/profiles/:key",
|
|
42
|
+
validate("param", z.object({ key: MailProfileKeySchema })),
|
|
43
|
+
validate("json", MailProfileInputSchema),
|
|
44
|
+
async (c) => {
|
|
45
|
+
const result = await outgoingMailStore.put(c.req.valid("param").key, c.req.valid("json"), context(c));
|
|
46
|
+
return c.json(result.profile, result.created ? 201 : 200);
|
|
47
|
+
},
|
|
48
|
+
)
|
|
49
|
+
.post("/profiles/:key/default", async (c) => c.json(await outgoingMailStore.setDefault(c.req.param("key")!, context(c))))
|
|
50
|
+
.delete("/profiles/:key", async (c) => {
|
|
51
|
+
await outgoingMailStore.delete(c.req.param("key")!, context(c));
|
|
52
|
+
return c.body(null, 204);
|
|
53
|
+
})
|
|
54
|
+
.post("/profiles/:key/test", validate("json", z.object({ recipient: z.email() }).strict()), async (c) => {
|
|
55
|
+
await outgoingMailTest.send(c.req.param("key")!, c.req.valid("json").recipient, context(c));
|
|
56
|
+
return c.json({ ok: true as const });
|
|
57
|
+
})
|
|
58
|
+
.get("/apps", async (c) => c.json(await outgoingMailStore.apps()))
|
|
59
|
+
.put("/apps/:appId", validate("json", MailAppAccessSchema), async (c) =>
|
|
60
|
+
c.json(await outgoingMailStore.setAppAccess(c.req.param("appId")!, c.req.valid("json"), context(c))),
|
|
61
|
+
);
|
|
62
|
+
export default createAdminOutgoingMailRoutes();
|
package/src/api/index.ts
CHANGED
|
@@ -29,6 +29,7 @@ import adminCoreSettingsRoutes from "./admin-core-settings";
|
|
|
29
29
|
import adminIdentityRoutes from "./admin-identity";
|
|
30
30
|
import adminLifecycleRoutes from "./admin-lifecycle";
|
|
31
31
|
import adminLinuxIdentityRoutes from "./admin-linux-identities";
|
|
32
|
+
import adminOutgoingMailRoutes from "./admin-outgoing-mail";
|
|
32
33
|
import adminRailRoutes from "./admin-rail";
|
|
33
34
|
import { adminAnnouncementRoutes, announcementRoutes } from "./announcements";
|
|
34
35
|
import { createAppApprovalRoutes } from "./app-approval";
|
|
@@ -77,6 +78,7 @@ const buildCoreApi = (options: CoreApiOptions) => {
|
|
|
77
78
|
.route("/admin/core/ai-projects", adminAiProjectsRoutes)
|
|
78
79
|
.route("/admin/core/ai-skills", adminAiSkillsRoutes)
|
|
79
80
|
.route("/admin/core/announcements", adminAnnouncementRoutes)
|
|
81
|
+
.route("/admin/core/outgoing-mail", adminOutgoingMailRoutes)
|
|
80
82
|
.route("/admin/core/settings", adminCoreSettingsRoutes)
|
|
81
83
|
.route("/admin/core/rail", adminRailRoutes)
|
|
82
84
|
.route("/admin/core/linux-identities", adminLinuxIdentityRoutes)
|