@k2b/cloud 0.40.0 → 0.41.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 +6 -3
- package/src/_internal/capability-sentences.ts +2 -1
- package/src/_internal/define-app.ts +13 -3
- package/src/_internal/heartbeat.ts +203 -14
- package/src/_internal/registry.ts +28 -1
- package/src/_internal/setup-lock.ts +103 -0
- package/src/ai/chat/action-card.ts +69 -3
- package/src/ai/chat/action-question.tsx +12 -1
- package/src/ai/chat/action-text.ts +288 -0
- package/src/ai/chat/tool-action-words.ts +31 -0
- package/src/ai/chat/tool-action.tsx +1 -26
- package/src/ai/executor.ts +10 -2
- package/src/ai/firecrawl-tools.ts +15 -2
- package/src/ai/index.ts +1 -0
- package/src/ai/migrate.ts +15 -0
- package/src/ai/open-questions.ts +172 -0
- package/src/ai/question-text.ts +8 -0
- package/src/ai/routes.ts +51 -5
- package/src/ai/store.ts +18 -0
- package/src/ai/testing.ts +36 -0
- package/src/ai/tool-sentences.ts +152 -34
- package/src/ai/tool-wording.ts +12 -6
- package/src/ai/tools.ts +8 -3
- package/src/ai/types.ts +16 -2
- package/src/api/app-approval-actions.ts +31 -7
- package/src/api/auth/schemas.ts +3 -0
- package/src/api/auth.ts +54 -18
- package/src/api/mcp.ts +27 -3
- package/src/api/me.ts +79 -3
- package/src/approvals/Approvals.tsx +25 -63
- package/src/approvals/approval-card.ts +68 -0
- package/src/contracts/app-approval.ts +3 -3
- package/src/contracts/capabilities.ts +1 -1
- package/src/contracts/shared.ts +3 -0
- package/src/server/middleware/auth.ts +4 -2
- package/src/services/accounts/app.ts +23 -3
- package/src/services/accounts/users.ts +12 -1
- package/src/services/approvals/device.ts +49 -33
- package/src/services/approvals/index.ts +1 -0
- package/src/services/approvals/list.ts +1 -1
- package/src/services/approvals/question.ts +68 -0
- package/src/services/audit/index.ts +1 -0
- package/src/services/auth-flows/ipa.ts +39 -2
- package/src/services/auth-flows/password-reset.ts +32 -6
- package/src/services/index.ts +2 -0
- package/src/services/pdf/markdown.ts +160 -2
- package/src/services/session/index.ts +203 -29
- package/src/ssr/HelpPanelArticle.island.tsx +308 -0
- package/src/ssr/HelpPanelBack.island.tsx +32 -0
- package/src/ssr/HelpPanelMenu.island.tsx +133 -0
- package/src/ssr/HelpPanelSearch.island.tsx +123 -0
- package/src/ssr/HelpPanelShortcuts.island.tsx +22 -0
- package/src/ssr/HelpPanelView.island.tsx +27 -0
- package/src/ssr/LayoutHelp.tsx +182 -540
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +6 -7
- package/src/ssr/LayoutHelpPanel.server.browser-harness.tsx +131 -0
- package/src/ssr/LayoutHelpPanel.server.tsx +115 -0
- package/src/ssr/__snapshots__/LayoutHelp.ssr.render.test.tsx.snap +29 -0
- package/src/ssr/help.ts +4 -3
- package/src/ssr/index.ts +1 -0
- package/src/ssr/islands/index.ts +3 -4
- package/src/ssr/layout-help-panel-controls.tsx +43 -0
- package/src/ssr/layout-help-panel-document.ts +67 -0
- package/src/ssr/layout-help-panel-lifecycle.ts +32 -0
- package/src/ssr/layout-help-panel-menu.tsx +113 -0
- package/src/ssr/layout-help-panel-modules.ts +7 -0
- package/src/ssr/layout-help-panel-reader.tsx +159 -0
- package/src/ssr/layout-help-panel-search.tsx +69 -0
- package/src/ssr/layout-help-panel-shortcuts.ts +7 -0
- package/src/ssr/layout-help-panel-state.ts +141 -0
- package/src/ssr/layout-help-panel-types.ts +19 -0
- package/src/ssr/layout-help-views.tsx +484 -0
- package/src/ssr/panel-idle.ts +9 -0
- package/src/styles/approvals.css +6 -0
- package/src/styles/effects.css +15 -0
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { sql } from "bun";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { APPROVALS_PAGE_SIZE } from "../contracts/approvals";
|
|
4
|
+
import {
|
|
5
|
+
CapabilityActionCallReviewSchema,
|
|
6
|
+
CapabilityActionReviewDataSchema,
|
|
7
|
+
CapabilitySemanticLinkSchema,
|
|
8
|
+
} from "../contracts/capabilities";
|
|
9
|
+
import { openRequests } from "../services/approvals/list";
|
|
10
|
+
import { logger } from "../services/logging";
|
|
11
|
+
import { reviewShortText, toolActionCard, toolBlockReview } from "./chat/action-card";
|
|
12
|
+
import { aiToolIcon } from "./chat/message-utils";
|
|
13
|
+
import { legacyOutcomeWords } from "./chat/tool-action-words";
|
|
14
|
+
import { type AiTurnBlock, toolBlockId } from "./protocol";
|
|
15
|
+
|
|
16
|
+
export type AiOpenQuestion = {
|
|
17
|
+
/** The approval request id (approvals.requests.id). */
|
|
18
|
+
id: string;
|
|
19
|
+
/** Short id of the chat the question waits in. */
|
|
20
|
+
conversationId: string;
|
|
21
|
+
/** The chat's current title, at most 200 code points; chat content, never Cloud's words. */
|
|
22
|
+
conversationTitle: string | null;
|
|
23
|
+
/** The question's data-chat-anchor: the id of the tool block the chat renders it in. */
|
|
24
|
+
anchor: string;
|
|
25
|
+
/** The question's short form, plain text, at most 160 code points. */
|
|
26
|
+
short: string;
|
|
27
|
+
/** The app the question is about, as the card shows it. */
|
|
28
|
+
app: { name: string; icon: string; accent?: string };
|
|
29
|
+
createdAt: string;
|
|
30
|
+
};
|
|
31
|
+
|
|
32
|
+
export type AiOpenQuestions = {
|
|
33
|
+
items: AiOpenQuestion[];
|
|
34
|
+
/** All open questions of the person, across pages. */
|
|
35
|
+
count: number;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type QuestionRow = {
|
|
39
|
+
id: string;
|
|
40
|
+
conversationId: string;
|
|
41
|
+
conversationTitle: string | null;
|
|
42
|
+
displayCallId: string;
|
|
43
|
+
appId: string;
|
|
44
|
+
sentence: string;
|
|
45
|
+
toolName: string;
|
|
46
|
+
createdAt: Date;
|
|
47
|
+
block: Extract<AiTurnBlock, { kind: "tool" }> | null;
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
const log = logger("ai:open-questions");
|
|
51
|
+
const legacyReviewSchema = z.object({
|
|
52
|
+
question: z.string().min(1),
|
|
53
|
+
rows: z.array(z.object({ label: z.string(), value: z.string(), format: z.enum(["date", "date-time"]).optional() })),
|
|
54
|
+
markdown: z.string().optional(),
|
|
55
|
+
summary: z.string().optional(),
|
|
56
|
+
links: z.array(CapabilitySemanticLinkSchema).optional(),
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
const ownedRequests = (ownerUserId: string) => sql`
|
|
60
|
+
SELECT r.* FROM (${openRequests(ownerUserId)}) r
|
|
61
|
+
WHERE EXISTS (
|
|
62
|
+
SELECT 1 FROM ai.pending_actions p JOIN ai.conversations c ON c.id=p.conversation_id
|
|
63
|
+
WHERE p.approval_id=r.id AND p.status='pending' AND p.kind IN ('approval','custom_approval')
|
|
64
|
+
AND c.created_by_user_id=${ownerUserId}::uuid
|
|
65
|
+
)`;
|
|
66
|
+
|
|
67
|
+
/** One bounded, owner-scoped snapshot, using exactly the chat's stored question card. */
|
|
68
|
+
export const listAiOpenQuestions = async (input: { ownerUserId: string; locale: string; timeZone: string }): Promise<AiOpenQuestions> => {
|
|
69
|
+
const [counts] = await sql<{ count: number }[]>`
|
|
70
|
+
SELECT count(*)::int AS count FROM (${ownedRequests(input.ownerUserId)}) r`;
|
|
71
|
+
const rows = await sql<QuestionRow[]>`
|
|
72
|
+
WITH page AS (
|
|
73
|
+
SELECT * FROM (${ownedRequests(input.ownerUserId)}) r
|
|
74
|
+
ORDER BY r.created_at DESC, r.id DESC LIMIT ${APPROVALS_PAGE_SIZE}
|
|
75
|
+
)
|
|
76
|
+
SELECT r.id, r.app_id AS "appId", r.sentence, r.created_at AS "createdAt",
|
|
77
|
+
question.short_id AS "conversationId", question.title AS "conversationTitle",
|
|
78
|
+
question.display_call_id AS "displayCallId", question.tool_name AS "toolName", question.block
|
|
79
|
+
FROM page r JOIN LATERAL (
|
|
80
|
+
SELECT c.short_id, c.title, p.tool_name,
|
|
81
|
+
CASE WHEN p.kind='custom_approval'
|
|
82
|
+
THEN COALESCE(substring(p.call_id FROM '^(.*)-approval-[0-9]+$'), p.call_id)
|
|
83
|
+
ELSE p.call_id END AS display_call_id,
|
|
84
|
+
(SELECT block FROM jsonb_array_elements(
|
|
85
|
+
CASE WHEN jsonb_typeof(t.live_blocks)='array' THEN t.live_blocks ELSE '[]'::jsonb END
|
|
86
|
+
) AS blocks(block)
|
|
87
|
+
WHERE block->>'kind'='tool' AND block->>'callId'=p.call_id LIMIT 1) AS block
|
|
88
|
+
FROM ai.pending_actions p JOIN ai.conversations c ON c.id=p.conversation_id
|
|
89
|
+
JOIN ai.turns t ON t.id=p.turn_id
|
|
90
|
+
WHERE p.approval_id=r.id AND p.status='pending' AND p.kind IN ('approval','custom_approval')
|
|
91
|
+
AND c.created_by_user_id=${input.ownerUserId}::uuid
|
|
92
|
+
ORDER BY p.created_at DESC, p.id DESC LIMIT 1
|
|
93
|
+
) question ON true
|
|
94
|
+
ORDER BY r.created_at DESC, r.id DESC`;
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
count: counts?.count ?? 0,
|
|
98
|
+
items: rows.map((row) => openQuestionFromRow(row, input)),
|
|
99
|
+
};
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/** Stop at the payload bound without splitting a Unicode code point or copying the rest of a legacy message. */
|
|
103
|
+
const boundedText = (text: string, max: number): string => {
|
|
104
|
+
const points: string[] = [];
|
|
105
|
+
for (const point of text) {
|
|
106
|
+
if (points.length === max) return `${points.slice(0, max - 1).join("")}…`;
|
|
107
|
+
points.push(point);
|
|
108
|
+
}
|
|
109
|
+
return text;
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
/** Present one stored question without database access. */
|
|
113
|
+
export const openQuestionFromRow = (row: QuestionRow, input: { locale: string; timeZone: string }): AiOpenQuestion => {
|
|
114
|
+
const block = row.block;
|
|
115
|
+
const presentation = block?.presentation;
|
|
116
|
+
const question: AiOpenQuestion = {
|
|
117
|
+
id: row.id,
|
|
118
|
+
conversationId: row.conversationId,
|
|
119
|
+
conversationTitle: row.conversationTitle === null ? null : boundedText(row.conversationTitle, 200),
|
|
120
|
+
// The id the chat renders the card under: a custom approval's card carries its parent call, also when a stored
|
|
121
|
+
// block still has the approval's own id (see normalizeCustomApprovalBlocks in client/projection.ts).
|
|
122
|
+
anchor: toolBlockId(row.displayCallId),
|
|
123
|
+
short: boundedText(row.sentence.split(/\r?\n/, 1)[0] ?? "", 160),
|
|
124
|
+
app: {
|
|
125
|
+
name: typeof presentation?.appName === "string" ? presentation.appName : block ? "Cloud" : row.appId,
|
|
126
|
+
icon: aiToolIcon(
|
|
127
|
+
typeof block?.name === "string" ? block.name : row.toolName,
|
|
128
|
+
typeof presentation?.appIcon === "string" ? presentation.appIcon : undefined,
|
|
129
|
+
),
|
|
130
|
+
...(typeof presentation?.appAccent === "string" ? { accent: presentation.appAccent } : {}),
|
|
131
|
+
},
|
|
132
|
+
createdAt: row.createdAt.toISOString(),
|
|
133
|
+
};
|
|
134
|
+
if (!block) return question;
|
|
135
|
+
try {
|
|
136
|
+
if (
|
|
137
|
+
typeof block.id !== "string" ||
|
|
138
|
+
!block.id ||
|
|
139
|
+
typeof block.name !== "string" ||
|
|
140
|
+
!block.name ||
|
|
141
|
+
typeof block.callId !== "string" ||
|
|
142
|
+
!["running", "awaiting_approval", "awaiting_client", "completed", "failed", "rejected"].includes(block.status)
|
|
143
|
+
)
|
|
144
|
+
throw new Error("Malformed tool block");
|
|
145
|
+
if (block.review == null && block.approval?.review != null) CapabilityActionReviewDataSchema.parse(block.approval.review);
|
|
146
|
+
const review = toolBlockReview(block);
|
|
147
|
+
if (review !== null) {
|
|
148
|
+
if ("legacy" in review) legacyReviewSchema.parse(review.legacy);
|
|
149
|
+
else CapabilityActionCallReviewSchema.parse(review);
|
|
150
|
+
}
|
|
151
|
+
const card = toolActionCard(block, {
|
|
152
|
+
locale: input.locale,
|
|
153
|
+
timeZone: input.timeZone,
|
|
154
|
+
outcome: legacyOutcomeWords(block, input.locale, input.timeZone),
|
|
155
|
+
});
|
|
156
|
+
const short =
|
|
157
|
+
review && !("legacy" in review)
|
|
158
|
+
? reviewShortText(review, { locale: input.locale, timeZone: input.timeZone, app: card.app })
|
|
159
|
+
: card.questionText;
|
|
160
|
+
if (
|
|
161
|
+
typeof short !== "string" ||
|
|
162
|
+
typeof card.app.name !== "string" ||
|
|
163
|
+
typeof card.app.icon !== "string" ||
|
|
164
|
+
(card.app.accent !== undefined && typeof card.app.accent !== "string")
|
|
165
|
+
)
|
|
166
|
+
throw new Error("Malformed question card");
|
|
167
|
+
return { ...question, short: boundedText(short, 160), app: card.app };
|
|
168
|
+
} catch {
|
|
169
|
+
log.warn("Failed to present open question", { approvalId: row.id, conversationId: row.conversationId });
|
|
170
|
+
return question;
|
|
171
|
+
}
|
|
172
|
+
};
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The plain-text form of an Assistant question, the same one the chat card shows: for `cld`, notifications, and Cloud
|
|
3
|
+
* Login. Pure and browser-safe, in its own entry so neither the CLI bundle nor browser bundles that use
|
|
4
|
+
* `@k2b/cloud/ai/browser` pull it in by accident.
|
|
5
|
+
*/
|
|
6
|
+
export { type ApprovalCardInput, approvalCard } from "../approvals/approval-card";
|
|
7
|
+
export type { ActionCard } from "./chat/action-card";
|
|
8
|
+
export { type ActionQuestionText, type ActionTextRow, actionCardText, actionQuestionLines } from "./chat/action-text";
|
package/src/ai/routes.ts
CHANGED
|
@@ -2,10 +2,11 @@ import { type Context, Hono } from "hono";
|
|
|
2
2
|
import { bodyLimit } from "hono/body-limit";
|
|
3
3
|
import { describeRoute } from "hono-openapi";
|
|
4
4
|
import { z } from "zod";
|
|
5
|
-
import { listCapabilities } from "../_internal/registry";
|
|
5
|
+
import { getApp, listCapabilities } from "../_internal/registry";
|
|
6
6
|
import { QUERY_TIMEOUT_MS } from "../api/capabilities";
|
|
7
7
|
import { ErrorResponseSchema } from "../contracts";
|
|
8
8
|
import { APPROVAL_CLIENT_HEADER } from "../contracts/approvals";
|
|
9
|
+
import { capabilityActionPolicy } from "../contracts/capabilities";
|
|
9
10
|
import {
|
|
10
11
|
type AccessSubject,
|
|
11
12
|
type AuthContext,
|
|
@@ -26,12 +27,14 @@ import { isAppSessionActor } from "../server/actor";
|
|
|
26
27
|
import { isRequestCredentialCurrent } from "../server/middleware/auth";
|
|
27
28
|
import { noTransform } from "../server/middleware/no-transform";
|
|
28
29
|
import { REQUEST_IDLE_TIMEOUT_SECONDS, setRequestTimeout } from "../server/request-timeout";
|
|
29
|
-
import { approvalDecisionChannel } from "../services/approvals";
|
|
30
|
+
import { approvalDecisionChannel, approvals } from "../services/approvals";
|
|
31
|
+
import { pendingApprovalReview } from "../services/approvals/question";
|
|
30
32
|
import { ImageMetadataError } from "../services/image-metadata";
|
|
31
33
|
import { logger } from "../services/logging";
|
|
32
34
|
import { coreSettings } from "../services/settings/api";
|
|
35
|
+
import { resolveAppIdentityPresentation } from "../shared/app-presentation";
|
|
33
36
|
import { readThemeFromCookieHeader } from "../shared/theme";
|
|
34
|
-
import { isUnknownActionOutcome } from "./action-review";
|
|
37
|
+
import { type AiStoredActionReview, isLegacyActionReview, isUnknownActionOutcome, storedActionReview } from "./action-review";
|
|
35
38
|
import { assistantAiSettingsState, listAssistantAiModels, resolveAssistantAudioModel, selectAssistantAiModelId } from "./assistant-models";
|
|
36
39
|
import { AI_AUDIO_MAX_BYTES } from "./audio-format";
|
|
37
40
|
import { buildAiCapabilityCatalog } from "./capabilities";
|
|
@@ -1352,8 +1355,51 @@ export const aiRoutes = (() => {
|
|
|
1352
1355
|
if (!conversation) return notFound(c);
|
|
1353
1356
|
const turn = await loadTurn(conversation, c.req.param("turnId"));
|
|
1354
1357
|
if (!turn) return notFound(c);
|
|
1355
|
-
const
|
|
1356
|
-
|
|
1358
|
+
const scope = { conversationId: conversation.id, turnId: turn.id };
|
|
1359
|
+
const actions = await listPendingAiTurnActions(scope);
|
|
1360
|
+
if (!actions.some((action) => action.type === "approval_request"))
|
|
1361
|
+
return respond(c, ok(actions.map((action) => publicPendingAction(action, conversation.shortId, turn.shortId))));
|
|
1362
|
+
const [records, reviews, config] = await Promise.all([
|
|
1363
|
+
aiConversations.listPendingActionRecords(scope),
|
|
1364
|
+
aiConversations.listTurnToolReviews(scope),
|
|
1365
|
+
aiConversations.getTurnRunConfig(scope),
|
|
1366
|
+
]);
|
|
1367
|
+
const locale = config?.kind !== "compact" ? (config?.locale ?? getLocale(c)) : getLocale(c);
|
|
1368
|
+
let capabilities: Promise<Awaited<ReturnType<typeof listCapabilities>> | null> | undefined;
|
|
1369
|
+
const presented = await Promise.all(
|
|
1370
|
+
actions.map(async (action) => {
|
|
1371
|
+
const result = publicPendingAction(action, conversation.shortId, turn.shortId);
|
|
1372
|
+
if (action.type !== "approval_request") return result;
|
|
1373
|
+
const record = records.find((record) => record.callId === action.callId);
|
|
1374
|
+
const request = record?.approvalId ? await approvals.get(record.approvalId) : null;
|
|
1375
|
+
const reviewCallId = record?.kind === "custom_approval" ? action.callId.replace(/-approval-[0-9]+$/, "") : action.callId;
|
|
1376
|
+
const stored = reviews.get(reviewCallId);
|
|
1377
|
+
let review: AiStoredActionReview | null = stored && !isLegacyActionReview(stored) ? stored : null;
|
|
1378
|
+
if (!review && request) {
|
|
1379
|
+
review = pendingApprovalReview({ storedReview: null, details: request.details });
|
|
1380
|
+
if (review && !isLegacyActionReview(review) && !review.ask) {
|
|
1381
|
+
// As the approvals page: the declared reason when the review names none, read once per listing.
|
|
1382
|
+
capabilities ??= listCapabilities().catch(() => null);
|
|
1383
|
+
const manifest = (await capabilities)?.find((entry) => entry.appId === request.appId)?.manifest;
|
|
1384
|
+
review = storedActionReview(review, manifest ? capabilityActionPolicy(manifest, request.action) : undefined);
|
|
1385
|
+
}
|
|
1386
|
+
}
|
|
1387
|
+
const appId = request?.appId;
|
|
1388
|
+
// The name is presentation: a registry that cannot answer leaves the app ID, never the waiting approvals unread.
|
|
1389
|
+
const app = appId && appId !== "assistant" ? await getApp(appId).catch(() => null) : null;
|
|
1390
|
+
return {
|
|
1391
|
+
...result,
|
|
1392
|
+
...(review && !isLegacyActionReview(review) ? { review } : {}),
|
|
1393
|
+
app: {
|
|
1394
|
+
id: appId ?? "assistant",
|
|
1395
|
+
name: app ? resolveAppIdentityPresentation(app, locale).name : appId && appId !== "assistant" ? appId : "Cloud",
|
|
1396
|
+
},
|
|
1397
|
+
chatTitle: conversation.title,
|
|
1398
|
+
...(request ? { expiresAt: request.expiresAt.toISOString() } : {}),
|
|
1399
|
+
};
|
|
1400
|
+
}),
|
|
1401
|
+
);
|
|
1402
|
+
return respond(c, ok(presented));
|
|
1357
1403
|
})
|
|
1358
1404
|
.get("/conversations/:conversationId/stream", async (c) => {
|
|
1359
1405
|
const ctx = await resolveContext(c);
|
package/src/ai/store.ts
CHANGED
|
@@ -3607,6 +3607,24 @@ export const aiConversations: AiConversationService = {
|
|
|
3607
3607
|
return row ? rowToPendingActionRecord(row) : null;
|
|
3608
3608
|
},
|
|
3609
3609
|
|
|
3610
|
+
listTurnToolReviews: async (input) => {
|
|
3611
|
+
// Only the review metadata, never the messages themselves, which can carry large tool results.
|
|
3612
|
+
const rows = await sql<{ tool_reviews: Record<string, AiStoredActionReview> | null }[]>`
|
|
3613
|
+
SELECT meta->'toolReviews' AS tool_reviews
|
|
3614
|
+
FROM (
|
|
3615
|
+
SELECT meta, seq, conversation_id, loop_id, role FROM ai.messages
|
|
3616
|
+
UNION ALL
|
|
3617
|
+
SELECT meta, seq, conversation_id, loop_id, role FROM ai.task_messages
|
|
3618
|
+
) messages
|
|
3619
|
+
WHERE conversation_id = ${input.conversationId}
|
|
3620
|
+
AND loop_id = ${input.turnId}
|
|
3621
|
+
AND role = 'assistant'
|
|
3622
|
+
AND meta ? 'toolReviews'
|
|
3623
|
+
ORDER BY seq ASC
|
|
3624
|
+
`;
|
|
3625
|
+
return new Map(rows.flatMap((row) => Object.entries(row.tool_reviews ?? {})));
|
|
3626
|
+
},
|
|
3627
|
+
|
|
3610
3628
|
listPendingActionRecords: async (input) => {
|
|
3611
3629
|
const rows = await sql<PendingActionRow[]>`
|
|
3612
3630
|
SELECT *
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { actionFixtures } from "./chat/action-question.fixtures";
|
|
2
|
+
import { type AiOpenQuestion, openQuestionFromRow } from "./open-questions";
|
|
3
|
+
import type { AiTurnBlock } from "./protocol";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Demo Assistant questions for host tests and screenshots, in German and English, with fixed times. The demo data may
|
|
7
|
+
* change in any release: assert on what your host renders from it, not on its wording.
|
|
8
|
+
*/
|
|
9
|
+
export const aiQuestionFixtures = (locale: "de" | "en"): Record<string, { block: Extract<AiTurnBlock, { kind: "tool" }> }> =>
|
|
10
|
+
actionFixtures(locale);
|
|
11
|
+
|
|
12
|
+
/** A waiting question as `listAiOpenQuestions` presents it from the chat's tool block. */
|
|
13
|
+
export const aiOpenQuestionFixture = (
|
|
14
|
+
input: {
|
|
15
|
+
id: string;
|
|
16
|
+
conversationId: string;
|
|
17
|
+
conversationTitle: string | null;
|
|
18
|
+
createdAt: string;
|
|
19
|
+
block: Extract<AiTurnBlock, { kind: "tool" }>;
|
|
20
|
+
},
|
|
21
|
+
context: { locale: string; timeZone: string },
|
|
22
|
+
): AiOpenQuestion =>
|
|
23
|
+
openQuestionFromRow(
|
|
24
|
+
{
|
|
25
|
+
id: input.id,
|
|
26
|
+
conversationId: input.conversationId,
|
|
27
|
+
conversationTitle: input.conversationTitle,
|
|
28
|
+
displayCallId: input.block.callId,
|
|
29
|
+
appId: input.block.presentation?.kind === "capability" ? input.block.presentation.appId : "core",
|
|
30
|
+
sentence: "",
|
|
31
|
+
toolName: input.block.name,
|
|
32
|
+
createdAt: new Date(input.createdAt),
|
|
33
|
+
block: input.block,
|
|
34
|
+
},
|
|
35
|
+
context,
|
|
36
|
+
);
|
package/src/ai/tool-sentences.ts
CHANGED
|
@@ -1,38 +1,156 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { actionReview } from "../capabilities/reviews";
|
|
2
|
+
import type { CapabilityActionCallReview } from "../contracts/capabilities";
|
|
3
|
+
import { CodeActionInput } from "./browser-code-contracts";
|
|
2
4
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
5
|
+
// Like the catalog these forms replace, code runs name no "not run" form: code asks about what it does inside the run,
|
|
6
|
+
// so a declined or stopped question does not mean the code did not run. No tool names a "rejected" form, so a declined
|
|
7
|
+
// call keeps Cloud's own words.
|
|
8
|
+
const forms = {
|
|
9
|
+
en: {
|
|
10
|
+
code_run: {
|
|
11
|
+
action: "Run code",
|
|
12
|
+
running: "Running code",
|
|
13
|
+
done: "Ran code",
|
|
14
|
+
confirm: "Run code",
|
|
15
|
+
decline: "Do not run",
|
|
16
|
+
},
|
|
17
|
+
code_action: {
|
|
18
|
+
action: "Run app action {action}",
|
|
19
|
+
running: "Running app action {action}",
|
|
20
|
+
done: "Ran app action {action}",
|
|
21
|
+
confirm: "Run action",
|
|
22
|
+
decline: "Do not run",
|
|
23
|
+
},
|
|
24
|
+
web_extract: {
|
|
25
|
+
action: "Read page on {site}",
|
|
26
|
+
running: "Reading page on {site}",
|
|
27
|
+
done: "Read page on {site}",
|
|
28
|
+
confirm: "Read page",
|
|
29
|
+
decline: "Do not read",
|
|
30
|
+
notRun: "Page on {site} not read",
|
|
31
|
+
},
|
|
32
|
+
fetch_file: {
|
|
33
|
+
action: "Download file from {site}",
|
|
34
|
+
running: "Downloading file from {site}",
|
|
35
|
+
done: "Downloaded file from {site}",
|
|
36
|
+
confirm: "Download file",
|
|
37
|
+
decline: "Do not download",
|
|
38
|
+
notRun: "File from {site} not downloaded",
|
|
39
|
+
},
|
|
40
|
+
write_file: {
|
|
41
|
+
action: "Write {file}",
|
|
42
|
+
running: "Writing {file}",
|
|
43
|
+
done: "Wrote {file}",
|
|
44
|
+
confirm: "Write file",
|
|
45
|
+
decline: "Do not write",
|
|
46
|
+
notRun: "{file} not written",
|
|
47
|
+
},
|
|
18
48
|
},
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
},
|
|
49
|
+
de: {
|
|
50
|
+
code_run: {
|
|
51
|
+
action: "Code ausführen",
|
|
52
|
+
running: "Code wird ausgeführt",
|
|
53
|
+
done: "Code ausgeführt",
|
|
54
|
+
confirm: "Code ausführen",
|
|
55
|
+
decline: "Nicht ausführen",
|
|
56
|
+
},
|
|
57
|
+
code_action: {
|
|
58
|
+
action: "App-Aktion {action} ausführen",
|
|
59
|
+
running: "App-Aktion {action} wird ausgeführt",
|
|
60
|
+
done: "App-Aktion {action} ausgeführt",
|
|
61
|
+
confirm: "Aktion ausführen",
|
|
62
|
+
decline: "Nicht ausführen",
|
|
63
|
+
},
|
|
64
|
+
web_extract: {
|
|
65
|
+
action: "Seite auf {site} lesen",
|
|
66
|
+
running: "Seite auf {site} wird gelesen",
|
|
67
|
+
done: "Seite auf {site} gelesen",
|
|
68
|
+
confirm: "Seite lesen",
|
|
69
|
+
decline: "Nicht lesen",
|
|
70
|
+
notRun: "Seite auf {site} nicht gelesen",
|
|
71
|
+
},
|
|
72
|
+
fetch_file: {
|
|
73
|
+
action: "Datei von {site} herunterladen",
|
|
74
|
+
running: "Datei von {site} wird heruntergeladen",
|
|
75
|
+
done: "Datei von {site} heruntergeladen",
|
|
76
|
+
confirm: "Datei herunterladen",
|
|
77
|
+
decline: "Nicht herunterladen",
|
|
78
|
+
notRun: "Datei von {site} nicht heruntergeladen",
|
|
79
|
+
},
|
|
80
|
+
write_file: {
|
|
81
|
+
action: "{file} schreiben",
|
|
82
|
+
running: "{file} wird geschrieben",
|
|
83
|
+
done: "{file} geschrieben",
|
|
84
|
+
confirm: "Datei schreiben",
|
|
85
|
+
decline: "Nicht schreiben",
|
|
86
|
+
notRun: "{file} nicht geschrieben",
|
|
36
87
|
},
|
|
37
88
|
},
|
|
38
|
-
} as const
|
|
89
|
+
} as const;
|
|
90
|
+
|
|
91
|
+
/** The bound of a `fields` preview value. */
|
|
92
|
+
const ADDRESS_MAX = 2000;
|
|
93
|
+
const readableAddress = (address: string): string | null => {
|
|
94
|
+
try {
|
|
95
|
+
return decodeURI(address);
|
|
96
|
+
} catch {
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const isRecord = (value: unknown): value is Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value);
|
|
102
|
+
|
|
103
|
+
/** Cloud tools use the same per-call review contract as application Actions. Safe for historical, incomplete inputs. */
|
|
104
|
+
export const cloudToolReview = (name: string, args: unknown, locale: string): CapabilityActionCallReview | null => {
|
|
105
|
+
if (!isRecord(args)) return null;
|
|
106
|
+
const language = /^de(?:-|$)/i.test(locale) ? "de" : "en";
|
|
107
|
+
const input = args;
|
|
108
|
+
const sentence = forms[language];
|
|
109
|
+
try {
|
|
110
|
+
if (name === "code_run") {
|
|
111
|
+
return actionReview({ locale: language, sentence: sentence.code_run }, {});
|
|
112
|
+
}
|
|
113
|
+
if (name === "code_action") {
|
|
114
|
+
const parsed = CodeActionInput.shape.action.safeParse(input.action);
|
|
115
|
+
if (!parsed.success) return null;
|
|
116
|
+
return actionReview({ locale: language, sentence: sentence.code_action }, { action: { kind: "text", value: parsed.data } });
|
|
117
|
+
}
|
|
118
|
+
if (name === "web_extract" || name === "fetch_file") {
|
|
119
|
+
if (typeof input.url !== "string" || !input.url.trim()) return null;
|
|
120
|
+
const href = input.url;
|
|
121
|
+
const url = new URL(href);
|
|
122
|
+
if (!url.host) return null;
|
|
123
|
+
const label = language === "de" ? "Adresse" : "Address";
|
|
124
|
+
const review = (rows: { label: string; value: string }[]) =>
|
|
125
|
+
actionReview(
|
|
126
|
+
{ locale: language, sentence: sentence[name] },
|
|
127
|
+
// A place title holds 200 characters; the address rows keep the whole host.
|
|
128
|
+
{ site: { kind: "place", title: url.host.length > 200 ? `${url.host.slice(0, 99)}…${url.host.slice(-100)}` : url.host } },
|
|
129
|
+
{ preview: { kind: "fields", rows } },
|
|
130
|
+
);
|
|
131
|
+
// One address row holds at most 2,000 characters. An address whose encoded form is longer shows as written;
|
|
132
|
+
// if even that does not fit, the encoded address continues over numbered rows, so every character stays visible.
|
|
133
|
+
for (const address of [href, readableAddress(href)])
|
|
134
|
+
if (address && address.length <= ADDRESS_MAX) {
|
|
135
|
+
try {
|
|
136
|
+
return review([{ label, value: address }]);
|
|
137
|
+
} catch {
|
|
138
|
+
// Try the next form.
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
const parts = Array.from({ length: Math.ceil(href.length / ADDRESS_MAX) }, (_, index) =>
|
|
142
|
+
href.slice(index * ADDRESS_MAX, (index + 1) * ADDRESS_MAX),
|
|
143
|
+
);
|
|
144
|
+
return review(parts.map((value, index) => ({ label: `${label} ${index + 1}/${parts.length}`, value })));
|
|
145
|
+
}
|
|
146
|
+
if (name === "write_file") {
|
|
147
|
+
if (typeof input.path !== "string" || !input.path.trim()) return null;
|
|
148
|
+
const file = input.path.trim().split("/").at(-1);
|
|
149
|
+
if (!file) return null;
|
|
150
|
+
return actionReview({ locale: language, sentence: sentence.write_file }, { file: { kind: "object", title: file.slice(0, 200) } });
|
|
151
|
+
}
|
|
152
|
+
} catch {
|
|
153
|
+
// Historical calls can predate the current bounds or carry partial input; they keep their generic tool title.
|
|
154
|
+
}
|
|
155
|
+
return null;
|
|
156
|
+
};
|
package/src/ai/tool-wording.ts
CHANGED
|
@@ -4,13 +4,12 @@ import {
|
|
|
4
4
|
capabilityActionSubject,
|
|
5
5
|
capabilityReviewSentence,
|
|
6
6
|
capabilitySentenceText,
|
|
7
|
-
resolveCapabilityActionSentences,
|
|
8
7
|
} from "../_internal/capability-sentences";
|
|
9
8
|
import { CapabilityActionReceiptSchema } from "../contracts/capabilities";
|
|
10
9
|
import { type AiStoredActionReview, isLegacyActionReview, storedActionReview } from "./action-review";
|
|
11
|
-
import {
|
|
10
|
+
import { isRecord } from "./chat/message-utils";
|
|
12
11
|
import type { AiTurnBlock } from "./protocol";
|
|
13
|
-
import {
|
|
12
|
+
import { cloudToolReview } from "./tool-sentences";
|
|
14
13
|
|
|
15
14
|
type ToolBlock = Extract<AiTurnBlock, { kind: "tool" }>;
|
|
16
15
|
|
|
@@ -20,7 +19,7 @@ const reviewOf = (block: ToolBlock): AiStoredActionReview | null =>
|
|
|
20
19
|
|
|
21
20
|
/**
|
|
22
21
|
* How the chat words one call that acts on something: an app Action through its saved presentation, a
|
|
23
|
-
* Cloud tool through
|
|
22
|
+
* Cloud tool through its per-call review. `null` for calls that only read.
|
|
24
23
|
*/
|
|
25
24
|
export const toolWording = (block: ToolBlock, locale: string): CapabilityActionWording | null => {
|
|
26
25
|
const presentation = block.presentation;
|
|
@@ -32,8 +31,10 @@ export const toolWording = (block: ToolBlock, locale: string): CapabilityActionW
|
|
|
32
31
|
...(presentation.fields ? { fields: presentation.fields } : {}),
|
|
33
32
|
};
|
|
34
33
|
}
|
|
35
|
-
const
|
|
36
|
-
return
|
|
34
|
+
const review = cloudToolReview(block.name, block.args, locale);
|
|
35
|
+
return review
|
|
36
|
+
? { title: capabilitySentenceText(review.sentence.action, review.slots, { locale: review.locale, timeZone: "UTC" }) }
|
|
37
|
+
: null;
|
|
37
38
|
};
|
|
38
39
|
|
|
39
40
|
/**
|
|
@@ -45,6 +46,8 @@ export const toolSubject = (block: ToolBlock, locale: string, timeZone: string):
|
|
|
45
46
|
const review = reviewOf(block);
|
|
46
47
|
if (review && !isLegacyActionReview(review))
|
|
47
48
|
return capabilitySentenceText(review.sentence.action, review.slots, { locale: review.locale, timeZone });
|
|
49
|
+
const cloudReview = cloudToolReview(block.name, block.args, locale);
|
|
50
|
+
if (cloudReview) return capabilitySentenceText(cloudReview.sentence.action, cloudReview.slots, { locale: cloudReview.locale, timeZone });
|
|
48
51
|
const wording = toolWording(block, locale);
|
|
49
52
|
return wording ? capabilityActionSubject(wording, block.args, { locale, timeZone }) : null;
|
|
50
53
|
};
|
|
@@ -59,6 +62,9 @@ export const toolOutcome = (block: ToolBlock, key: "done" | "rejected" | "notRun
|
|
|
59
62
|
return capabilityReviewSentence(review, key, { timeZone });
|
|
60
63
|
}
|
|
61
64
|
if (review && key === "done" && review.legacy.summary) return review.legacy.summary;
|
|
65
|
+
const cloudReview = cloudToolReview(block.name, block.args, locale);
|
|
66
|
+
// Only a form the tool names; otherwise the card keeps Cloud's own outcome words for the call.
|
|
67
|
+
if (cloudReview) return key === "done" || cloudReview.sentence[key] ? capabilityReviewSentence(cloudReview, key, { timeZone }) : null;
|
|
62
68
|
const wording = toolWording(block, locale);
|
|
63
69
|
if (!wording) return null;
|
|
64
70
|
// App Actions return their data inside the result envelope; Cloud tools return the data itself.
|
package/src/ai/tools.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { Tool, ToolContext } from "@k2b/nessi";
|
|
2
2
|
import { defineTool as defineNessiTool } from "@k2b/nessi";
|
|
3
3
|
import type { z } from "zod";
|
|
4
|
+
import type { CapabilityActionCallReview } from "../contracts/capabilities";
|
|
4
5
|
import type { RequestActor } from "../server";
|
|
5
6
|
import { aiToolNeedsApproval } from "./approvals";
|
|
6
7
|
import type {
|
|
@@ -64,7 +65,7 @@ export const defineAiTool = <TInput extends z.ZodType, TOutput extends z.ZodType
|
|
|
64
65
|
/** Localized short status; never include payloads or secrets. */
|
|
65
66
|
reportProgress?: (message: string) => Promise<void>;
|
|
66
67
|
/** Asks like `requestApproval`, and lets the person remember the approval of `target`. */
|
|
67
|
-
requestApprovalFor?: (message: string, target: AiApprovalTarget) => Promise<boolean>;
|
|
68
|
+
requestApprovalFor?: (message: string, target: AiApprovalTarget, review?: CapabilityActionCallReview) => Promise<boolean>;
|
|
68
69
|
/** Relays a decision for an effect another platform component consumes and executes. */
|
|
69
70
|
requestRelayedApproval?: (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => Promise<boolean>;
|
|
70
71
|
/** Shows requests a website approval let through as they go out, so the chat keeps them whatever the outcome. */
|
|
@@ -122,6 +123,8 @@ export type AiToolPreparationContext = {
|
|
|
122
123
|
linkApproval?: (approvalCallId: string, approvalId: string) => void;
|
|
123
124
|
/** Hears what a nested approval asks for before the approval is requested. */
|
|
124
125
|
describeApproval?: (approvalCallId: string, target: AiApprovalTarget) => void;
|
|
126
|
+
/** Registers the deterministic review before requesting or consuming a nested approval. */
|
|
127
|
+
describeReview?: (approvalCallId: string, review: CapabilityActionCallReview) => void | Promise<void>;
|
|
125
128
|
reportWebsiteReceipts?: (callId: string, receipts: AiWebsiteReceipt[]) => Promise<void>;
|
|
126
129
|
};
|
|
127
130
|
|
|
@@ -168,10 +171,11 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
|
|
|
168
171
|
}
|
|
169
172
|
// Nessi numbers the approvals of one execution in request order; count them to name each one's call ID.
|
|
170
173
|
let approvals = 0;
|
|
171
|
-
const requestApproval = async (message: string, target?: AiApprovalTarget): Promise<boolean> => {
|
|
174
|
+
const requestApproval = async (message: string, target?: AiApprovalTarget, review?: CapabilityActionCallReview): Promise<boolean> => {
|
|
172
175
|
while (true) {
|
|
173
176
|
const approvalCallId = `${callId}-approval-${approvals++}`;
|
|
174
177
|
if (target) input.describeApproval?.(approvalCallId, target);
|
|
178
|
+
if (review) await input.describeReview?.(approvalCallId, review);
|
|
175
179
|
if (!(await ctx.requestApproval(message))) return false;
|
|
176
180
|
const used = await use(approvalCallId);
|
|
177
181
|
if (used === "approved") return true;
|
|
@@ -182,7 +186,8 @@ export const prepareAiTools = (input: AiToolPreparationContext & { tools?: AiRun
|
|
|
182
186
|
return {
|
|
183
187
|
...ctx,
|
|
184
188
|
requestApproval,
|
|
185
|
-
requestApprovalFor: (message: string, target: AiApprovalTarget) =>
|
|
189
|
+
requestApprovalFor: (message: string, target: AiApprovalTarget, review?: CapabilityActionCallReview) =>
|
|
190
|
+
requestApproval(message, target, review),
|
|
186
191
|
requestRelayedApproval: async (message: string, options: { approvalId?: string; target?: AiApprovalTarget }) => {
|
|
187
192
|
const approvalCallId = `${callId}-approval-${approvals++}`;
|
|
188
193
|
if (options.approvalId) input.linkApproval?.(approvalCallId, options.approvalId);
|