@k2b/cloud 0.39.0 → 0.40.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/README.md +8 -0
- package/package.json +8 -4
- package/src/_internal/app-favicon.ts +4 -2
- package/src/_internal/capabilities.ts +72 -48
- package/src/_internal/capability-reviews.ts +81 -0
- package/src/_internal/capability-schemas.ts +29 -0
- package/src/_internal/capability-sentences.ts +216 -1
- package/src/_internal/capability-streams.ts +7 -0
- package/src/_internal/define-app.ts +36 -15
- package/src/_internal/font-preloads.ts +2 -1
- package/src/_internal/runtime-watcher.ts +6 -0
- package/src/_internal/static-assets.ts +52 -3
- package/src/_internal/status-preserving-ssr.ts +2 -0
- package/src/access/PermissionEditor.tsx +2 -2
- package/src/account/EntitySearch.tsx +1 -1
- package/src/ai/action-review.ts +101 -0
- package/src/ai/background-review.ts +72 -0
- package/src/ai/capabilities.ts +29 -11
- package/src/ai/capability-execution.ts +76 -43
- package/src/ai/capability-result.ts +95 -0
- package/src/ai/chat/action-card.ts +398 -0
- package/src/ai/chat/action-messages.ts +129 -0
- package/src/ai/chat/action-question.browser-harness.tsx +245 -0
- package/src/ai/chat/action-question.fixtures.ts +733 -0
- package/src/ai/chat/action-question.tsx +669 -0
- package/src/ai/chat/blocks.tsx +19 -25
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/tool-action.tsx +481 -0
- package/src/ai/chat/turn-view.tsx +10 -160
- package/src/ai/chat/view-block.tsx +15 -5
- package/src/ai/chat-task-contracts.ts +4 -1
- package/src/ai/chat-task-reasons-migrate.ts +7 -0
- package/src/ai/chat-task-reasons.ts +1 -1
- package/src/ai/chat-tasks.ts +55 -25
- package/src/ai/client/controller.ts +59 -0
- package/src/ai/default-tools.ts +9 -1
- package/src/ai/executor.ts +190 -39
- package/src/ai/index.ts +2 -0
- package/src/ai/migrate.ts +11 -2
- package/src/ai/projects-routes.ts +3 -0
- package/src/ai/protocol.ts +14 -2
- package/src/ai/recent-resources.ts +240 -0
- package/src/ai/recent-sent-refs.ts +38 -0
- package/src/ai/routes.ts +145 -1
- package/src/ai/runtime.ts +1 -0
- package/src/ai/skills-routes.ts +3 -0
- package/src/ai/skills.ts +10 -1
- package/src/ai/store.ts +227 -24
- package/src/ai/tool-audit.ts +8 -0
- package/src/ai/tool-wording.ts +24 -2
- package/src/ai/types.ts +28 -3
- package/src/api/accounts-entities.ts +2 -1
- package/src/api/admin-ai-projects.ts +2 -0
- package/src/api/admin-ai-skills.ts +2 -0
- package/src/api/admin-ai-usage.ts +2 -0
- package/src/api/admin-geoip.ts +80 -0
- package/src/api/admin-identity.ts +3 -0
- package/src/api/admin-lifecycle.ts +3 -0
- package/src/api/admin-outgoing-mail.ts +2 -0
- package/src/api/app-approval.ts +46 -5
- package/src/api/approvals-loader.ts +23 -1
- package/src/api/approvals.ts +2 -0
- package/src/api/auth.ts +25 -6
- package/src/api/capabilities.ts +44 -11
- package/src/api/capability-streams.ts +12 -0
- package/src/api/help.ts +4 -28
- package/src/api/index.ts +6 -1
- package/src/api/lasting-access-messages.ts +17 -0
- package/src/api/mcp.ts +3 -0
- package/src/api/me-panel-consent.ts +71 -0
- package/src/api/me.ts +80 -33
- package/src/api/search.ts +3 -1
- package/src/api/sync-ops.ts +2 -0
- package/src/api/widgets.ts +3 -0
- package/src/approvals/Approvals.tsx +180 -238
- package/src/approvals/messages.ts +12 -24
- package/src/browser/CloudResourceSearch.browser-harness.tsx +10 -2
- package/src/browser/app-approval.ts +54 -5
- package/src/browser/commands.ts +22 -6
- package/src/browser/composer-catalog.ts +45 -3
- package/src/browser/composer-insert.ts +10 -0
- package/src/browser/composer-open-messages.ts +17 -0
- package/src/browser/composer-open.ts +110 -0
- package/src/browser/discoverable-command.ts +6 -0
- package/src/browser/hints.ts +6 -0
- package/src/browser/live-connection.ts +26 -2
- package/src/browser/navigation-apps.ts +7 -0
- package/src/browser/panel-context-bridge.ts +30 -0
- package/src/browser/panel-context-receiver.ts +53 -0
- package/src/browser/panel-menu.tsx +56 -0
- package/src/browser/panel-messages.ts +11 -0
- package/src/browser/panel-shortcuts.ts +31 -0
- package/src/browser/panel-slot.ts +412 -10
- package/src/browser/panel.ts +37 -0
- package/src/browser/search-commands.ts +6 -3
- package/src/capabilities/client.ts +8 -2
- package/src/capabilities/reviews.ts +205 -0
- package/src/capabilities/server.ts +10 -3
- package/src/capabilities/testing.ts +47 -0
- package/src/capabilities/types.ts +2 -2
- package/src/cli/account.ts +13 -3
- package/src/cli/admin/geoip.ts +115 -0
- package/src/cli/admin/index.ts +3 -0
- package/src/contracts/app-approval.ts +120 -13
- package/src/contracts/approvals.ts +2 -2
- package/src/contracts/capabilities.ts +410 -18
- package/src/contracts/geoip.ts +42 -0
- package/src/contracts/index.ts +3 -0
- package/src/contracts/panel-context-plain.ts +99 -0
- package/src/contracts/panel-context.ts +93 -0
- package/src/contracts/seen-hints.ts +35 -0
- package/src/server/client-ip.ts +5 -0
- package/src/server/help-payload.ts +38 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/middleware/middleware.ts +9 -0
- package/src/server/middleware/no-transform.ts +19 -0
- package/src/server/middleware/rate-limit.ts +4 -6
- package/src/server/request-context/address.ts +45 -0
- package/src/server/request-context/geoip.ts +380 -0
- package/src/server/request-context/index.ts +33 -0
- package/src/server/request-context/user-agent.ts +95 -0
- package/src/services/app-approval.ts +382 -59
- package/src/services/approvals/device.ts +7 -5
- package/src/services/approvals/index.ts +6 -5
- package/src/services/approvals/list.ts +26 -5
- package/src/services/auth-flows/notification-sender.ts +3 -0
- package/src/services/geoip/index.ts +164 -0
- package/src/services/geoip/store.ts +30 -0
- package/src/services/geoip/upload.ts +150 -0
- package/src/services/identity/routes.ts +5 -1
- package/src/services/index.ts +11 -0
- package/src/services/known-browsers.ts +312 -0
- package/src/services/login-address-store.ts +51 -0
- package/src/services/login-context.ts +118 -0
- package/src/services/mandates/index.ts +3 -0
- package/src/services/mandates/policy.ts +15 -3
- package/src/services/notifications/email-frame.ts +1 -1
- package/src/services/oauth-tokens.ts +16 -2
- package/src/services/panel-context-consent.ts +58 -0
- package/src/services/service-account-credentials.ts +21 -0
- package/src/services/session/index.ts +97 -16
- package/src/services/session/recent.ts +13 -0
- package/src/services/webauthn.ts +12 -0
- package/src/shared/email-html.ts +2 -0
- package/src/shared/help.ts +3 -0
- package/src/shared-text/cursor.ts +22 -0
- package/src/shared-text/documents.ts +142 -0
- package/src/shared-text/events.ts +43 -0
- package/src/shared-text/index.ts +12 -0
- package/src/shared-text/log.ts +48 -0
- package/src/shared-text/text-diff.ts +23 -0
- package/src/shared-text/types.ts +41 -0
- package/src/ssr/AdminLayout.tsx +2 -0
- package/src/ssr/AppLaunchpadPanel.tsx +2 -2
- package/src/ssr/CloudShell.island.tsx +10 -2
- package/src/ssr/Layout.tsx +25 -2
- package/src/ssr/LayoutHelp.browser-harness.tsx +2 -0
- package/src/ssr/LayoutHelp.tsx +435 -274
- package/src/ssr/LayoutHelpPanel.browser-harness.tsx +136 -0
- package/src/ssr/LayoutHelpTrigger.tsx +27 -2
- package/src/ssr/MobileNavigation.tsx +2 -2
- package/src/ssr/PanelLayout.tsx +9 -1
- package/src/ssr/PanelRuntime.island.tsx +7 -0
- package/src/ssr/PanelSlot.tsx +53 -3
- package/src/ssr/ShellRuntime.island.tsx +11 -2
- package/src/ssr/app-color.ts +14 -9
- package/src/ssr/app-navigation.ts +16 -3
- package/src/ssr/compact-shell.browser-harness.ts +30 -22
- package/src/ssr/dialog-app-colors.browser-harness.tsx +43 -0
- package/src/ssr/help-messages.ts +4 -0
- package/src/ssr/help.ts +20 -0
- package/src/ssr/hints.ts +25 -0
- package/src/ssr/index.ts +2 -0
- package/src/ssr/islands/index.ts +4 -3
- package/src/ssr/layout-context.ts +2 -0
- package/src/ssr/layout-help-article.server.ts +50 -0
- package/src/ssr/layout-help-start.ts +33 -7
- package/src/ssr/panel-consent.ts +29 -0
- package/src/ssr/panel-context.ts +10 -0
- package/src/ssr/panel-slot.browser-harness.tsx +53 -4
- package/src/ssr/platform-messages.ts +18 -0
- package/src/ssr/shell-data.tsx +2 -1
- package/src/styles/effects.css +198 -9
- package/src/styles/file-chooser.css +2 -1
- package/src/styles/global.css +49 -12
- package/src/styles/panel.css +47 -4
- package/src/styles/resource-search.css +6 -2
- package/src/styles/tokens.css +17 -11
- package/src/styles/utilities-buttons.css +3 -2
- package/src/styles/utilities-data.css +2 -1
- package/src/styles/utilities-feedback.css +2 -2
- package/src/styles/utilities-layout.css +3 -13
- package/src/styles/utilities-markdown-editor.css +6 -6
- package/src/styles/utilities-navigation.css +17 -22
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
import { resolveCapabilityManifestPresentation } from "../_internal/capabilities";
|
|
3
|
+
import { listCapabilities } from "../_internal/registry";
|
|
4
|
+
import { type CapabilityDispatchParams, dispatchCapability, QUERY_TIMEOUT_MS } from "../api/capabilities";
|
|
5
|
+
import {
|
|
6
|
+
CloudResourceRefSchema,
|
|
7
|
+
capabilityResultSchema,
|
|
8
|
+
cloudResourceRefAppId,
|
|
9
|
+
LinkedCloudResourceReferenceSchema,
|
|
10
|
+
resolveCapabilityResourceReader,
|
|
11
|
+
} from "../contracts/capabilities";
|
|
12
|
+
import type { CapabilityRegistryEntry } from "../contracts/registry";
|
|
13
|
+
import type { RequestAuthority } from "../server";
|
|
14
|
+
import { resolveAppIdentityPresentation } from "../shared/app-presentation";
|
|
15
|
+
import { LOCALE_HEADER } from "../shared/locale";
|
|
16
|
+
import { CODE_SOURCE_TOOLS } from "./code-source-contracts";
|
|
17
|
+
import { createCodeSourceTool } from "./code-source-tools";
|
|
18
|
+
import { RECENT_SENT_REF_LIMIT } from "./recent-sent-refs";
|
|
19
|
+
import { addCloudResourceHrefs } from "./resource-refs";
|
|
20
|
+
import { aiSkills } from "./skills";
|
|
21
|
+
import { aiConversations } from "./store";
|
|
22
|
+
import type { AiConversationService, AiRecentResource } from "./types";
|
|
23
|
+
|
|
24
|
+
export const AiRecentResourceSchema: z.ZodType<AiRecentResource> = z
|
|
25
|
+
.object({
|
|
26
|
+
ref: CloudResourceRefSchema,
|
|
27
|
+
title: LinkedCloudResourceReferenceSchema.shape.title.unwrap(),
|
|
28
|
+
icon: LinkedCloudResourceReferenceSchema.shape.icon,
|
|
29
|
+
href: LinkedCloudResourceReferenceSchema.shape.href,
|
|
30
|
+
kind: z.string().min(1).max(120),
|
|
31
|
+
preview: z.string().max(160).optional(),
|
|
32
|
+
appName: z.string().min(1).max(120),
|
|
33
|
+
count: z.number().int().positive(),
|
|
34
|
+
})
|
|
35
|
+
.strict();
|
|
36
|
+
|
|
37
|
+
const ReaderResult = capabilityResultSchema(z.unknown(), { consumer: true });
|
|
38
|
+
const StudioData = z.object({
|
|
39
|
+
id: z.string(),
|
|
40
|
+
title: LinkedCloudResourceReferenceSchema.shape.title.unwrap(),
|
|
41
|
+
icon: LinkedCloudResourceReferenceSchema.shape.icon,
|
|
42
|
+
description: z.string().optional(),
|
|
43
|
+
});
|
|
44
|
+
const studioReader = async (input: {
|
|
45
|
+
id: string;
|
|
46
|
+
conversationId: string;
|
|
47
|
+
authority: RequestAuthority;
|
|
48
|
+
locale: string;
|
|
49
|
+
signal: AbortSignal;
|
|
50
|
+
}): Promise<unknown> => {
|
|
51
|
+
const tool = createCodeSourceTool("code_read");
|
|
52
|
+
if (tool.location !== "server") throw new Error("Studio reader must run on the server.");
|
|
53
|
+
return tool.run(CODE_SOURCE_TOOLS.code_read.input.parse({ id: input.id }), {
|
|
54
|
+
actor: input.authority.actor,
|
|
55
|
+
conversationId: input.conversationId,
|
|
56
|
+
locale: input.locale,
|
|
57
|
+
signal: input.signal,
|
|
58
|
+
requestApproval: async () => false,
|
|
59
|
+
requestClientTool: async () => {
|
|
60
|
+
throw new Error("Recent resources cannot call client tools.");
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
export type AiRecentResourceDependencies = {
|
|
66
|
+
store?: Pick<AiConversationService, "listRecentSentResourceRefs">;
|
|
67
|
+
listRegistry?: () => Promise<CapabilityRegistryEntry[]>;
|
|
68
|
+
dispatch?: (params: CapabilityDispatchParams) => Promise<Response>;
|
|
69
|
+
skills?: Pick<typeof aiSkills, "list">;
|
|
70
|
+
studioReader?: typeof studioReader;
|
|
71
|
+
timeoutMs?: number;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
const german = (locale: string) => locale.toLowerCase().split("-")[0] === "de";
|
|
75
|
+
/** Skills and Studio apps belong to the Assistant, which has no capability presentation to read its name from. */
|
|
76
|
+
const assistantName = (locale: string) => (german(locale) ? "Assistent" : "Assistant");
|
|
77
|
+
|
|
78
|
+
/** First line only; reserve one character for the ellipsis and avoid splitting words. */
|
|
79
|
+
export const recentResourcePreview = (value: string | undefined): string | undefined => {
|
|
80
|
+
const line = value?.split(/\r\n?|\n/, 1)[0]?.trim();
|
|
81
|
+
if (!line) return undefined;
|
|
82
|
+
if (line.length <= 160) return line;
|
|
83
|
+
const clipped = line.slice(0, 159);
|
|
84
|
+
const boundary = clipped.search(/\s+\S*$/);
|
|
85
|
+
return `${(boundary > 0 ? clipped.slice(0, boundary) : clipped).trimEnd()}…`;
|
|
86
|
+
};
|
|
87
|
+
|
|
88
|
+
/** Enforce the shared budget even when a dependency ignores cancellation. */
|
|
89
|
+
const withinBudget = async <T>(signal: AbortSignal, run: () => Promise<T>): Promise<T> => {
|
|
90
|
+
signal.throwIfAborted();
|
|
91
|
+
let abort: () => void = () => {};
|
|
92
|
+
const aborted = new Promise<never>((_, reject) => {
|
|
93
|
+
abort = () => reject(signal.reason);
|
|
94
|
+
signal.addEventListener("abort", abort, { once: true });
|
|
95
|
+
});
|
|
96
|
+
try {
|
|
97
|
+
return await Promise.race([run(), aborted]);
|
|
98
|
+
} finally {
|
|
99
|
+
signal.removeEventListener("abort", abort);
|
|
100
|
+
}
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
export const listAiRecentResources = async (input: {
|
|
104
|
+
ownerUserId: string;
|
|
105
|
+
authority: RequestAuthority;
|
|
106
|
+
locale: string;
|
|
107
|
+
signal: AbortSignal;
|
|
108
|
+
dependencies?: AiRecentResourceDependencies;
|
|
109
|
+
}): Promise<AiRecentResource[]> => {
|
|
110
|
+
const deps = input.dependencies ?? {};
|
|
111
|
+
const signal = AbortSignal.any([input.signal, AbortSignal.timeout(deps.timeoutMs ?? QUERY_TIMEOUT_MS)]);
|
|
112
|
+
// Unreadable history is no answer, not an empty one: the request fails, so the browser asks again at its next opening.
|
|
113
|
+
const sent = await withinBudget(signal, () =>
|
|
114
|
+
(deps.store ?? aiConversations).listRecentSentResourceRefs({ ownerUserId: input.ownerUserId }),
|
|
115
|
+
);
|
|
116
|
+
if (!sent.length || signal.aborted) return [];
|
|
117
|
+
const candidates = sent.slice(0, RECENT_SENT_REF_LIMIT);
|
|
118
|
+
const skillIds = candidates.flatMap(({ ref }) => (ref.type === "core.ai.skill" ? [ref.id] : []));
|
|
119
|
+
// Exactly the sent Skills, so a large Skill catalog never pushes one out; failing, they are left out like a reader.
|
|
120
|
+
const skills = skillIds.length
|
|
121
|
+
? withinBudget(signal, () => (deps.skills ?? aiSkills).list(input.authority.accessSubject, { shortIds: skillIds })).catch(() => [])
|
|
122
|
+
: Promise.resolve([]);
|
|
123
|
+
const registry = candidates.some(({ ref }) => ref.type !== "core.ai.skill" && ref.type !== "assistant.artifact")
|
|
124
|
+
? withinBudget(signal, deps.listRegistry ?? listCapabilities).catch(() => [])
|
|
125
|
+
: Promise.resolve([]);
|
|
126
|
+
const resources = await Promise.all(
|
|
127
|
+
candidates.map(async (candidate): Promise<AiRecentResource | null> => {
|
|
128
|
+
try {
|
|
129
|
+
signal.throwIfAborted();
|
|
130
|
+
const { ref, count } = candidate;
|
|
131
|
+
if (ref.type === "core.ai.skill") {
|
|
132
|
+
const skill = (await skills).find((skill) => skill.shortId === ref.id && skill.enabled !== false);
|
|
133
|
+
if (!skill) return null;
|
|
134
|
+
return {
|
|
135
|
+
ref,
|
|
136
|
+
count,
|
|
137
|
+
title: skill.name.slice(0, 500),
|
|
138
|
+
icon: "ti ti-sparkles",
|
|
139
|
+
kind: "Skill",
|
|
140
|
+
preview: recentResourcePreview(skill.description),
|
|
141
|
+
appName: assistantName(input.locale),
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
if (ref.type === "assistant.artifact") {
|
|
145
|
+
const { actor, accessSubject, credentialKind, scopes } = input.authority;
|
|
146
|
+
// code_read reconstructs a user session authority. Only use it when that is
|
|
147
|
+
// exactly this request's authority; never widen scoped/delegated credentials.
|
|
148
|
+
if (
|
|
149
|
+
credentialKind !== "session" ||
|
|
150
|
+
scopes.length ||
|
|
151
|
+
actor.kind !== "user" ||
|
|
152
|
+
actor.delegation ||
|
|
153
|
+
actor.sessionKind === "app" ||
|
|
154
|
+
accessSubject.type !== "user" ||
|
|
155
|
+
accessSubject.userId !== actor.user.id
|
|
156
|
+
)
|
|
157
|
+
return null;
|
|
158
|
+
const answer = ReaderResult.parse(
|
|
159
|
+
await withinBudget(signal, () =>
|
|
160
|
+
(deps.studioReader ?? studioReader)({
|
|
161
|
+
id: ref.id,
|
|
162
|
+
conversationId: candidate.conversationId,
|
|
163
|
+
authority: input.authority,
|
|
164
|
+
locale: input.locale,
|
|
165
|
+
signal,
|
|
166
|
+
}),
|
|
167
|
+
),
|
|
168
|
+
);
|
|
169
|
+
signal.throwIfAborted();
|
|
170
|
+
const data = StudioData.parse(answer.data);
|
|
171
|
+
if (data.id !== ref.id || !data.title.trim()) return null;
|
|
172
|
+
const matched = answer.refs?.find((entry) => entry.type === ref.type && entry.id === ref.id);
|
|
173
|
+
const ownRef = matched ? LinkedCloudResourceReferenceSchema.parse(matched) : undefined;
|
|
174
|
+
const href = ownRef?.href ?? answer.links?.find((link) => link.rel === "open")?.href;
|
|
175
|
+
return {
|
|
176
|
+
ref,
|
|
177
|
+
count,
|
|
178
|
+
title: data.title,
|
|
179
|
+
icon: ownRef?.icon ?? data.icon,
|
|
180
|
+
href,
|
|
181
|
+
preview: recentResourcePreview(ownRef?.preview ?? data.description),
|
|
182
|
+
kind: german(input.locale) ? "Studio-App" : "Studio app",
|
|
183
|
+
appName: assistantName(input.locale),
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
const apps = await registry;
|
|
187
|
+
signal.throwIfAborted();
|
|
188
|
+
const appId = cloudResourceRefAppId(ref);
|
|
189
|
+
const app = apps.find((app) => app.appId === appId);
|
|
190
|
+
const reader = app && resolveCapabilityResourceReader(app.manifest, ref);
|
|
191
|
+
if (!app || !reader) return null;
|
|
192
|
+
const response = await withinBudget(signal, () =>
|
|
193
|
+
(deps.dispatch ?? dispatchCapability)({
|
|
194
|
+
request: new Request("http://localhost/api/ai/resources/recent", {
|
|
195
|
+
method: "POST",
|
|
196
|
+
headers: { [LOCALE_HEADER]: input.locale, "x-request-id": crypto.randomUUID() },
|
|
197
|
+
signal,
|
|
198
|
+
}),
|
|
199
|
+
kind: "queries",
|
|
200
|
+
origin: "assistant",
|
|
201
|
+
appId: app.appId,
|
|
202
|
+
capabilityId: reader.localId,
|
|
203
|
+
input: { id: ref.id },
|
|
204
|
+
authority: input.authority,
|
|
205
|
+
locale: input.locale,
|
|
206
|
+
}),
|
|
207
|
+
);
|
|
208
|
+
if (!response.ok) return null;
|
|
209
|
+
const answer = ReaderResult.parse(addCloudResourceHrefs(await withinBudget(signal, () => response.json()), apps));
|
|
210
|
+
const matched = answer.refs?.find((entry) => entry.type === ref.type && entry.id === ref.id);
|
|
211
|
+
const ownRef = matched ? LinkedCloudResourceReferenceSchema.parse(matched) : undefined;
|
|
212
|
+
if (!ownRef?.title?.trim()) return null;
|
|
213
|
+
signal.throwIfAborted();
|
|
214
|
+
const manifest = resolveCapabilityManifestPresentation(app.manifest, app.presentation, input.locale);
|
|
215
|
+
const type = manifest.types.find((type) => `${app.appId}.${type.localId}` === ref.type);
|
|
216
|
+
if (!type) return null;
|
|
217
|
+
const presentation = resolveAppIdentityPresentation(
|
|
218
|
+
{ name: app.appName, description: app.appDescription, presentation: app.appPresentation },
|
|
219
|
+
input.locale,
|
|
220
|
+
);
|
|
221
|
+
return {
|
|
222
|
+
ref,
|
|
223
|
+
count,
|
|
224
|
+
title: ownRef.title,
|
|
225
|
+
icon: (ownRef.icon ?? type.icon ?? app.appIcon)?.slice(0, 120),
|
|
226
|
+
href: ownRef.href,
|
|
227
|
+
kind: type.title.slice(0, 120),
|
|
228
|
+
preview: recentResourcePreview(ownRef.preview),
|
|
229
|
+
appName: presentation.name.slice(0, 120),
|
|
230
|
+
};
|
|
231
|
+
} catch {
|
|
232
|
+
return null;
|
|
233
|
+
}
|
|
234
|
+
}),
|
|
235
|
+
);
|
|
236
|
+
return resources.flatMap((resource) => {
|
|
237
|
+
const parsed = AiRecentResourceSchema.safeParse(resource);
|
|
238
|
+
return parsed.success ? [parsed.data] : [];
|
|
239
|
+
});
|
|
240
|
+
};
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import { parseAiResourceMarker } from "./resource-markers";
|
|
2
|
+
import type { AiRecentSentResourceRef } from "./types";
|
|
3
|
+
|
|
4
|
+
export const RECENT_SENT_REF_LIMIT = 8;
|
|
5
|
+
// Look back 25 messages/conversations per displayed ref: useful sparse history without
|
|
6
|
+
// scanning the owner's lifetime. insertMessageLocked (used by submitChatTurn and steering)
|
|
7
|
+
// bumps conversation.updated_at, so newly sent turns enter this indexed window.
|
|
8
|
+
export const RECENT_SENT_MESSAGE_SCAN_LIMIT = RECENT_SENT_REF_LIMIT * 25;
|
|
9
|
+
export const RECENT_SENT_CONVERSATION_SCAN_LIMIT = RECENT_SENT_MESSAGE_SCAN_LIMIT;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Rows arrive newest first and contain only marker text, never whole message bodies. Forking or copying a chat copies
|
|
13
|
+
* its messages with their short IDs, so a message ID counts once, however many chats hold a copy of it.
|
|
14
|
+
*/
|
|
15
|
+
export const collectRecentSentResourceRefs = (
|
|
16
|
+
messages: readonly { conversationId: string; messageId: string; lastSentAt: string; markers: readonly string[] }[],
|
|
17
|
+
): AiRecentSentResourceRef[] => {
|
|
18
|
+
const refs = new Map<string, AiRecentSentResourceRef>();
|
|
19
|
+
const counted = new Set<string>();
|
|
20
|
+
for (const message of messages) {
|
|
21
|
+
if (counted.has(message.messageId)) continue;
|
|
22
|
+
counted.add(message.messageId);
|
|
23
|
+
const seen = new Set<string>();
|
|
24
|
+
for (const text of message.markers) {
|
|
25
|
+
const marker = parseAiResourceMarker(text);
|
|
26
|
+
if (!marker) continue;
|
|
27
|
+
const key = JSON.stringify([marker.ref.type, marker.ref.id]);
|
|
28
|
+
if (seen.has(key)) continue;
|
|
29
|
+
seen.add(key);
|
|
30
|
+
const existing = refs.get(key);
|
|
31
|
+
if (existing) existing.count++;
|
|
32
|
+
else if (refs.size < RECENT_SENT_REF_LIMIT) {
|
|
33
|
+
refs.set(key, { ref: marker.ref, count: 1, lastSentAt: message.lastSentAt, conversationId: message.conversationId });
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return [...refs.values()];
|
|
38
|
+
};
|
package/src/ai/routes.ts
CHANGED
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
import { type Context, Hono } from "hono";
|
|
2
2
|
import { bodyLimit } from "hono/body-limit";
|
|
3
|
+
import { describeRoute } from "hono-openapi";
|
|
3
4
|
import { z } from "zod";
|
|
4
5
|
import { listCapabilities } from "../_internal/registry";
|
|
6
|
+
import { QUERY_TIMEOUT_MS } from "../api/capabilities";
|
|
7
|
+
import { ErrorResponseSchema } from "../contracts";
|
|
5
8
|
import { APPROVAL_CLIENT_HEADER } from "../contracts/approvals";
|
|
6
9
|
import {
|
|
7
10
|
type AccessSubject,
|
|
@@ -11,22 +14,28 @@ import {
|
|
|
11
14
|
fail,
|
|
12
15
|
getLocale,
|
|
13
16
|
getTimeZone,
|
|
17
|
+
jsonResponse,
|
|
14
18
|
ok,
|
|
15
19
|
type RequestActor,
|
|
16
20
|
rateLimit,
|
|
21
|
+
requiresAuth,
|
|
17
22
|
respond,
|
|
18
23
|
v,
|
|
19
24
|
} from "../server";
|
|
20
25
|
import { isAppSessionActor } from "../server/actor";
|
|
21
26
|
import { isRequestCredentialCurrent } from "../server/middleware/auth";
|
|
27
|
+
import { noTransform } from "../server/middleware/no-transform";
|
|
28
|
+
import { REQUEST_IDLE_TIMEOUT_SECONDS, setRequestTimeout } from "../server/request-timeout";
|
|
22
29
|
import { approvalDecisionChannel } from "../services/approvals";
|
|
23
30
|
import { ImageMetadataError } from "../services/image-metadata";
|
|
24
31
|
import { logger } from "../services/logging";
|
|
25
32
|
import { coreSettings } from "../services/settings/api";
|
|
26
33
|
import { readThemeFromCookieHeader } from "../shared/theme";
|
|
34
|
+
import { isUnknownActionOutcome } from "./action-review";
|
|
27
35
|
import { assistantAiSettingsState, listAssistantAiModels, resolveAssistantAudioModel, selectAssistantAiModelId } from "./assistant-models";
|
|
28
36
|
import { AI_AUDIO_MAX_BYTES } from "./audio-format";
|
|
29
37
|
import { buildAiCapabilityCatalog } from "./capabilities";
|
|
38
|
+
import { checkAiCapability, resolveAiCapabilityActor } from "./capability-execution";
|
|
30
39
|
import { aiChatMessages } from "./chat/messages";
|
|
31
40
|
import { getAiChatQuotas } from "./chat-quotas";
|
|
32
41
|
import { aiChatTasks } from "./chat-tasks";
|
|
@@ -67,6 +76,8 @@ import {
|
|
|
67
76
|
import { aiActorUser, aiPrefsUserId, aiUserPrefs } from "./prefs";
|
|
68
77
|
import { aiProjects } from "./projects";
|
|
69
78
|
import { projectPublicAiStoredMessages, publicAiStoredMessages } from "./public-projection";
|
|
79
|
+
import { AiRecentResourceSchema, listAiRecentResources } from "./recent-resources";
|
|
80
|
+
import { RECENT_SENT_REF_LIMIT } from "./recent-sent-refs";
|
|
70
81
|
import { type AiChatRequestContext, resolveAiChatRequestContext as resolveContext } from "./request-context";
|
|
71
82
|
import { aiResourceMarker } from "./resource-markers";
|
|
72
83
|
import { isConversationResourceCursor } from "./resource-refs";
|
|
@@ -85,9 +96,10 @@ import { AI_SHORT_ID_PATTERN } from "./short-id";
|
|
|
85
96
|
import { selectAiSkillCatalog } from "./skill-catalog";
|
|
86
97
|
import { createCloudAiLoadSkillTool, createCloudAiSearchSkillsTool } from "./skill-tool";
|
|
87
98
|
import { aiSkills } from "./skills";
|
|
88
|
-
import { aiConversations } from "./store";
|
|
99
|
+
import { AI_TOOL_CHECK_STALE_MS, aiConversations, claimAiToolCheck, readAiToolCheck, settleAiToolCheck } from "./store";
|
|
89
100
|
import { createAiConversationStreamResponse, loadAiStreamState } from "./stream";
|
|
90
101
|
import { composeAiSystemPrompt } from "./system-prompt";
|
|
102
|
+
import { aiToolAudit } from "./tool-audit";
|
|
91
103
|
import { aiToolPromptHints } from "./tools";
|
|
92
104
|
import type { AiConversation, AiTurn } from "./types";
|
|
93
105
|
|
|
@@ -305,6 +317,7 @@ export const aiRoutes = (() => {
|
|
|
305
317
|
|
|
306
318
|
return (
|
|
307
319
|
new Hono<AuthContext>()
|
|
320
|
+
.use("*", noTransform())
|
|
308
321
|
.use(rateLimit())
|
|
309
322
|
.use("*", auth.requireRole("authenticated"))
|
|
310
323
|
.get("/status", async (c) => {
|
|
@@ -459,6 +472,42 @@ export const aiRoutes = (() => {
|
|
|
459
472
|
});
|
|
460
473
|
return respond(c, ok({ prompt, renderedAt: new Date().toISOString() }));
|
|
461
474
|
})
|
|
475
|
+
.get(
|
|
476
|
+
"/resources/recent",
|
|
477
|
+
describeRoute({
|
|
478
|
+
tags: ["AI"],
|
|
479
|
+
summary: "List recently sent resources",
|
|
480
|
+
description: "Up to eight recently sent refs, with current reader-authorized metadata. Unreadable resources are omitted.",
|
|
481
|
+
...requiresAuth,
|
|
482
|
+
responses: {
|
|
483
|
+
200: jsonResponse(
|
|
484
|
+
z.object({ resources: z.array(AiRecentResourceSchema).max(RECENT_SENT_REF_LIMIT) }),
|
|
485
|
+
"Current recent resources",
|
|
486
|
+
),
|
|
487
|
+
401: jsonResponse(ErrorResponseSchema, "Authentication required"),
|
|
488
|
+
403: jsonResponse(ErrorResponseSchema, "AI context unavailable"),
|
|
489
|
+
},
|
|
490
|
+
}),
|
|
491
|
+
// Titles come from app readers, so a scoped token needs the read scope the capability routes ask for.
|
|
492
|
+
auth.requireOAuthScope("read", "admin"),
|
|
493
|
+
async (c) => {
|
|
494
|
+
c.header("Cache-Control", "no-store");
|
|
495
|
+
const ctx = await resolveContext(c);
|
|
496
|
+
if (ctx instanceof Response) return ctx;
|
|
497
|
+
setRequestTimeout(c, Math.ceil(QUERY_TIMEOUT_MS / 1000) + REQUEST_IDLE_TIMEOUT_SECONDS);
|
|
498
|
+
try {
|
|
499
|
+
const resources = await listAiRecentResources({
|
|
500
|
+
ownerUserId: ctx.ownerUserId,
|
|
501
|
+
authority: auth.getAuthority(c),
|
|
502
|
+
locale: getLocale(c),
|
|
503
|
+
signal: c.req.raw.signal,
|
|
504
|
+
});
|
|
505
|
+
return respond(c, ok({ resources }));
|
|
506
|
+
} finally {
|
|
507
|
+
setRequestTimeout(c, REQUEST_IDLE_TIMEOUT_SECONDS);
|
|
508
|
+
}
|
|
509
|
+
},
|
|
510
|
+
)
|
|
462
511
|
.get("/resources", v("query", UserResourcesQuerySchema), async (c) => {
|
|
463
512
|
const ctx = await resolveContext(c);
|
|
464
513
|
if (ctx instanceof Response) return ctx;
|
|
@@ -1158,6 +1207,101 @@ export const aiRoutes = (() => {
|
|
|
1158
1207
|
await abortAiTurn({ conversationId: conversation.id, turnId: turn.id });
|
|
1159
1208
|
return respond(c, ok({ ok: true }));
|
|
1160
1209
|
})
|
|
1210
|
+
.post("/conversations/:conversationId/turns/:turnId/calls/:callId/check", async (c) => {
|
|
1211
|
+
const ctx = await resolveContext(c);
|
|
1212
|
+
if (ctx instanceof Response) return ctx;
|
|
1213
|
+
// The check runs with the person's session authority, so a scoped token or another client cannot borrow it.
|
|
1214
|
+
if (ctx.actor.kind !== "user" || !ctx.signedInSession)
|
|
1215
|
+
return respond(c, fail(err.forbidden("Action outcome checks require a signed-in browser session.")));
|
|
1216
|
+
const conversation = await loadConversation(c, ctx);
|
|
1217
|
+
if (!conversation) return notFound(c);
|
|
1218
|
+
const turn = await loadTurn(conversation, c.req.param("turnId"));
|
|
1219
|
+
const callId = c.req.param("callId");
|
|
1220
|
+
if (!turn || !callId) return notFound(c);
|
|
1221
|
+
const target = { conversationId: conversation.id, turnId: turn.id, callId };
|
|
1222
|
+
// The app's Query may take its whole budget; the request must outlast it.
|
|
1223
|
+
setRequestTimeout(c, Math.ceil(QUERY_TIMEOUT_MS / 1000) + REQUEST_IDLE_TIMEOUT_SECONDS);
|
|
1224
|
+
try {
|
|
1225
|
+
const messages = await aiConversations.listTurnMessages({
|
|
1226
|
+
conversationId: conversation.id,
|
|
1227
|
+
loopId: turn.id,
|
|
1228
|
+
includeCompacted: true,
|
|
1229
|
+
});
|
|
1230
|
+
const message = messages.find(
|
|
1231
|
+
({ message }) =>
|
|
1232
|
+
message.role === "assistant" && message.content.some((block) => block.type === "tool_call" && block.id === callId),
|
|
1233
|
+
);
|
|
1234
|
+
if (!message) return notFound(c);
|
|
1235
|
+
const review = message.meta?.toolReviews?.[callId];
|
|
1236
|
+
const previous = message.meta?.toolChecks?.[callId];
|
|
1237
|
+
if (!review || !("sentence" in review) || !review.check) return respond(c, fail(err.conflict("This call has no outcome check.")));
|
|
1238
|
+
if (!previous) {
|
|
1239
|
+
// An unknown tool result ends executor recovery for this call, even while its turn is live. Without
|
|
1240
|
+
// a result, only a terminal cut-off call may be checked. Both paths require evidence of dispatch.
|
|
1241
|
+
const terminal = turn.status === "completed" || turn.status === "failed" || turn.status === "aborted";
|
|
1242
|
+
const result = messages.find(({ message }) => message.role === "tool_result" && message.callId === callId)?.message;
|
|
1243
|
+
const eligible = result
|
|
1244
|
+
? result.role === "tool_result" && result.isError === true && isUnknownActionOutcome(result.result)
|
|
1245
|
+
: terminal;
|
|
1246
|
+
if (!eligible || !(await aiToolAudit.hasCapabilityDispatch(target)))
|
|
1247
|
+
return respond(c, fail(err.conflict("This call has no outcome check.")));
|
|
1248
|
+
}
|
|
1249
|
+
// A settled check answers again as it was stored, so a client whose last response got lost can recover
|
|
1250
|
+
// without running the Query again.
|
|
1251
|
+
if (previous?.state === "done" || previous?.state === "not_done") return respond(c, ok(previous));
|
|
1252
|
+
const block =
|
|
1253
|
+
message.message.role === "assistant"
|
|
1254
|
+
? message.message.content.find((block) => block.type === "tool_call" && block.id === callId)
|
|
1255
|
+
: undefined;
|
|
1256
|
+
const catalog = buildAiCapabilityCatalog(await listCapabilities());
|
|
1257
|
+
const presentation = message.meta?.toolPresentations?.[callId];
|
|
1258
|
+
const entry = catalog.find(
|
|
1259
|
+
(entry) =>
|
|
1260
|
+
entry.kind === "action" &&
|
|
1261
|
+
(entry.name === (block?.type === "tool_call" ? block.name : undefined) ||
|
|
1262
|
+
entry.providerName === (block?.type === "tool_call" ? block.name : undefined)) &&
|
|
1263
|
+
(!presentation || presentation.appId === entry.appId),
|
|
1264
|
+
);
|
|
1265
|
+
if (!entry) return respond(c, fail(err.conflict("The Action's app is unavailable. Try the check again when it returns.")));
|
|
1266
|
+
const authority = await resolveAiCapabilityActor({
|
|
1267
|
+
conversationId: conversation.id,
|
|
1268
|
+
persistedActor: ctx.actor,
|
|
1269
|
+
store: aiConversations,
|
|
1270
|
+
});
|
|
1271
|
+
// One check at a time per call, across Core replicas and the executor: the claim is one conditional write,
|
|
1272
|
+
// and no transaction stays open while the app's Query runs.
|
|
1273
|
+
const claimedAt = new Date().toISOString();
|
|
1274
|
+
const claimed = await claimAiToolCheck({
|
|
1275
|
+
...target,
|
|
1276
|
+
first: !previous,
|
|
1277
|
+
at: claimedAt,
|
|
1278
|
+
staleBefore: new Date(Date.now() - AI_TOOL_CHECK_STALE_MS).toISOString(),
|
|
1279
|
+
});
|
|
1280
|
+
if (!claimed) {
|
|
1281
|
+
const stored = await readAiToolCheck(target);
|
|
1282
|
+
if (stored?.state === "done" || stored?.state === "not_done") return respond(c, ok(stored));
|
|
1283
|
+
return respond(c, fail(err.conflict("An outcome check is already running for this call.")));
|
|
1284
|
+
}
|
|
1285
|
+
const answered = await checkAiCapability({
|
|
1286
|
+
...target,
|
|
1287
|
+
entry,
|
|
1288
|
+
check: review.check,
|
|
1289
|
+
catalog,
|
|
1290
|
+
authority,
|
|
1291
|
+
// The receipt joins the stored review's sentence, so it is asked for in that language.
|
|
1292
|
+
locale: review.locale,
|
|
1293
|
+
context: { callId, signal: c.req.raw.signal },
|
|
1294
|
+
});
|
|
1295
|
+
if (await settleAiToolCheck({ ...target, claimedAt, check: answered })) return respond(c, ok(answered));
|
|
1296
|
+
// The claim went stale and a newer check took over; its stored result is the answer.
|
|
1297
|
+
const stored = await readAiToolCheck(target);
|
|
1298
|
+
return stored && stored.state !== "checking"
|
|
1299
|
+
? respond(c, ok(stored))
|
|
1300
|
+
: respond(c, fail(err.conflict("An outcome check is already running for this call.")));
|
|
1301
|
+
} finally {
|
|
1302
|
+
setRequestTimeout(c, REQUEST_IDLE_TIMEOUT_SECONDS);
|
|
1303
|
+
}
|
|
1304
|
+
})
|
|
1161
1305
|
.post(
|
|
1162
1306
|
"/conversations/:conversationId/turns/:turnId/actions/:callId",
|
|
1163
1307
|
bodyLimit({ maxSize: AI_CLIENT_TOOL_ACTION_MAX_BYTES }),
|
package/src/ai/runtime.ts
CHANGED
|
@@ -47,6 +47,7 @@ import type {
|
|
|
47
47
|
import { isAiImageMediaType } from "./types";
|
|
48
48
|
import { validateAiTurnRequest } from "./validate";
|
|
49
49
|
|
|
50
|
+
export { createAiTurnCodeAndClientTools } from "./default-tools";
|
|
50
51
|
export type { ValidateAiTurnInput } from "./validate";
|
|
51
52
|
export { isAiSettingsError, validateAiTurnRequest } from "./validate";
|
|
52
53
|
|
package/src/ai/skills-routes.ts
CHANGED
|
@@ -3,6 +3,7 @@ import { HTTPException } from "hono/http-exception";
|
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { AuthenticatedPrincipalSchema } from "../contracts/shared";
|
|
5
5
|
import { type AuthContext, auth, err, fail, ok, rateLimit, respond, v } from "../server";
|
|
6
|
+
import { noTransform } from "../server/middleware/no-transform";
|
|
6
7
|
import { localizedAppName } from "../ssr/runtime";
|
|
7
8
|
import { aiProjects } from "./projects";
|
|
8
9
|
import {
|
|
@@ -63,6 +64,8 @@ type AiSkillsRouteDependencies = {
|
|
|
63
64
|
|
|
64
65
|
const buildAiSkillsRoutes = (dependencies: AiSkillsRouteDependencies = {}) =>
|
|
65
66
|
new Hono<AuthContext>()
|
|
67
|
+
// The list combines skills from separate grants; the writer of one can rename it next to another skill's text.
|
|
68
|
+
.use("*", noTransform())
|
|
66
69
|
.onError((error, c) => {
|
|
67
70
|
if (error instanceof AiSkillAppForbiddenError) return respond(c, fail(err.forbidden(error.message)));
|
|
68
71
|
if (error instanceof HTTPException) return error.getResponse();
|
package/src/ai/skills.ts
CHANGED
|
@@ -522,7 +522,15 @@ const skillSnapshotFiles = (skill: AiSkill): { path: string; content: string }[]
|
|
|
522
522
|
...skill.references.map((reference) => ({ path: `${skill.name}/${reference.path}`, content: reference.content })),
|
|
523
523
|
];
|
|
524
524
|
|
|
525
|
-
type SkillListOptions = {
|
|
525
|
+
type SkillListOptions = {
|
|
526
|
+
name?: string;
|
|
527
|
+
/** Only these Skills, by short ID, such as the ones a person recently sent. */
|
|
528
|
+
shortIds?: readonly string[];
|
|
529
|
+
query?: string;
|
|
530
|
+
enabledOnly?: boolean;
|
|
531
|
+
modelInvocableOnly?: boolean;
|
|
532
|
+
limit?: number;
|
|
533
|
+
};
|
|
526
534
|
async function listSkillSummaries(
|
|
527
535
|
subject: AccessSubject | null,
|
|
528
536
|
options: SkillListOptions & {
|
|
@@ -551,6 +559,7 @@ async function listSkillSummaries(
|
|
|
551
559
|
ON disabled.skill_id = skill.id AND disabled.user_id = ${userId}::uuid
|
|
552
560
|
WHERE access.permission <> 'none'
|
|
553
561
|
AND ${options.name === undefined ? sql`true` : sql`skill.name = ${options.name}`}
|
|
562
|
+
AND ${options.shortIds === undefined ? sql`true` : sql`skill.short_id = ANY(${toPgTextArray([...options.shortIds])}::text[])`}
|
|
554
563
|
AND (${!options.manageOnly} OR access.permission='admin')
|
|
555
564
|
AND ${options.project ? sql`EXISTS(SELECT 1 FROM ai.project_skills link WHERE link.skill_id=skill.id AND link.project_id=${options.project.id}::uuid) = ${options.project.linked}` : sql`true`}
|
|
556
565
|
AND (${!options.modelInvocableOnly} OR NOT manual_only)
|