@digimuza/system-core 0.2.0 → 0.3.0-rc.1
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/INTEGRATION.md +209 -3
- package/README.md +23 -0
- package/dist/ai/api.d.ts +120 -0
- package/dist/ai/api.d.ts.map +1 -0
- package/dist/ai/api.js +446 -0
- package/dist/ai/api.js.map +1 -0
- package/dist/ai/card.d.ts +27 -0
- package/dist/ai/card.d.ts.map +1 -0
- package/dist/ai/card.js +84 -0
- package/dist/ai/card.js.map +1 -0
- package/dist/ai/contract.d.ts +364 -0
- package/dist/ai/contract.d.ts.map +1 -0
- package/dist/ai/contract.js +101 -0
- package/dist/ai/contract.js.map +1 -0
- package/dist/ai/display.d.ts +20 -0
- package/dist/ai/display.d.ts.map +1 -0
- package/dist/ai/display.js +35 -0
- package/dist/ai/display.js.map +1 -0
- package/dist/ai/index.d.ts +7 -0
- package/dist/ai/index.d.ts.map +1 -0
- package/dist/ai/index.js +10 -0
- package/dist/ai/index.js.map +1 -0
- package/dist/ai/manifest.d.ts +25 -0
- package/dist/ai/manifest.d.ts.map +1 -0
- package/dist/ai/manifest.js +15 -0
- package/dist/ai/manifest.js.map +1 -0
- package/dist/ai/runner.d.ts +71 -0
- package/dist/ai/runner.d.ts.map +1 -0
- package/dist/ai/runner.js +246 -0
- package/dist/ai/runner.js.map +1 -0
- package/dist/ai/tools.d.ts +42 -0
- package/dist/ai/tools.d.ts.map +1 -0
- package/dist/ai/tools.js +117 -0
- package/dist/ai/tools.js.map +1 -0
- package/dist/audit/csv.d.ts +31 -0
- package/dist/audit/csv.d.ts.map +1 -0
- package/dist/audit/csv.js +39 -0
- package/dist/audit/csv.js.map +1 -0
- package/dist/audit/index.d.ts +5 -0
- package/dist/audit/index.d.ts.map +1 -0
- package/dist/audit/index.js +6 -0
- package/dist/audit/index.js.map +1 -0
- package/dist/convex/functions.d.ts +39 -0
- package/dist/convex/functions.d.ts.map +1 -1
- package/dist/convex/functions.js +38 -6
- package/dist/convex/functions.js.map +1 -1
- package/dist/convex/index.d.ts +1 -1
- package/dist/convex/index.d.ts.map +1 -1
- package/dist/convex/index.js.map +1 -1
- package/dist/core/access.d.ts +14 -0
- package/dist/core/access.d.ts.map +1 -1
- package/dist/core/access.js +62 -0
- package/dist/core/access.js.map +1 -1
- package/dist/core/audit-module.d.ts +5 -0
- package/dist/core/audit-module.d.ts.map +1 -0
- package/dist/core/audit-module.js +25 -0
- package/dist/core/audit-module.js.map +1 -0
- package/dist/core/audit.d.ts +31 -0
- package/dist/core/audit.d.ts.map +1 -1
- package/dist/core/audit.js +46 -2
- package/dist/core/audit.js.map +1 -1
- package/dist/core/catalog.d.ts +41 -0
- package/dist/core/catalog.d.ts.map +1 -1
- package/dist/core/catalog.js +45 -0
- package/dist/core/catalog.js.map +1 -1
- package/dist/core/context.d.ts +8 -2
- package/dist/core/context.d.ts.map +1 -1
- package/dist/core/context.js +4 -1
- package/dist/core/context.js.map +1 -1
- package/dist/core/index.d.ts +2 -0
- package/dist/core/index.d.ts.map +1 -1
- package/dist/core/index.js +2 -0
- package/dist/core/index.js.map +1 -1
- package/dist/core/notify.d.ts +37 -0
- package/dist/core/notify.d.ts.map +1 -0
- package/dist/core/notify.js +16 -0
- package/dist/core/notify.js.map +1 -0
- package/dist/core/roles.d.ts.map +1 -1
- package/dist/core/roles.js +5 -1
- package/dist/core/roles.js.map +1 -1
- package/dist/core/system-modules.d.ts +4 -0
- package/dist/core/system-modules.d.ts.map +1 -1
- package/dist/core/system-modules.js +11 -0
- package/dist/core/system-modules.js.map +1 -1
- package/dist/finance/ai-tools.d.ts +55 -0
- package/dist/finance/ai-tools.d.ts.map +1 -0
- package/dist/finance/ai-tools.js +65 -0
- package/dist/finance/ai-tools.js.map +1 -0
- package/dist/finance/index.d.ts +87 -1
- package/dist/finance/index.d.ts.map +1 -1
- package/dist/finance/index.js +92 -38
- package/dist/finance/index.js.map +1 -1
- package/dist/finance/search.d.ts +15 -0
- package/dist/finance/search.d.ts.map +1 -0
- package/dist/finance/search.js +51 -0
- package/dist/finance/search.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/modules/index.d.ts +1 -1
- package/dist/modules/index.d.ts.map +1 -1
- package/dist/modules/index.js +1 -1
- package/dist/modules/index.js.map +1 -1
- package/dist/notifications/api.d.ts +130 -0
- package/dist/notifications/api.d.ts.map +1 -0
- package/dist/notifications/api.js +384 -0
- package/dist/notifications/api.js.map +1 -0
- package/dist/notifications/contract.d.ts +132 -0
- package/dist/notifications/contract.d.ts.map +1 -0
- package/dist/notifications/contract.js +59 -0
- package/dist/notifications/contract.js.map +1 -0
- package/dist/notifications/index.d.ts +6 -0
- package/dist/notifications/index.d.ts.map +1 -0
- package/dist/notifications/index.js +9 -0
- package/dist/notifications/index.js.map +1 -0
- package/dist/search/index.d.ts +95 -0
- package/dist/search/index.d.ts.map +1 -0
- package/dist/search/index.js +151 -0
- package/dist/search/index.js.map +1 -0
- package/package.json +41 -2
- package/src/ai/api.ts +535 -0
- package/src/ai/card.ts +91 -0
- package/src/ai/contract.ts +245 -0
- package/src/ai/display.ts +38 -0
- package/src/ai/index.ts +9 -0
- package/src/ai/manifest.ts +16 -0
- package/src/ai/runner.ts +275 -0
- package/src/ai/tools.ts +128 -0
- package/src/audit/csv.ts +60 -0
- package/src/audit/index.ts +7 -0
- package/src/convex/functions.ts +54 -7
- package/src/convex/index.ts +1 -1
- package/src/core/access.ts +55 -1
- package/src/core/audit-module.ts +26 -0
- package/src/core/audit.ts +68 -2
- package/src/core/catalog.ts +63 -0
- package/src/core/context.ts +13 -2
- package/src/core/index.ts +2 -0
- package/src/core/notify.ts +52 -0
- package/src/core/roles.ts +5 -1
- package/src/core/system-modules.ts +12 -0
- package/src/finance/ai-tools.ts +68 -0
- package/src/finance/index.ts +119 -42
- package/src/finance/search.ts +58 -0
- package/src/index.ts +2 -0
- package/src/modules/index.ts +3 -0
- package/src/notifications/api.ts +477 -0
- package/src/notifications/contract.ts +142 -0
- package/src/notifications/index.ts +8 -0
- package/src/search/index.ts +198 -0
package/src/ai/api.ts
ADDED
|
@@ -0,0 +1,535 @@
|
|
|
1
|
+
// The AI module's core data & API layer: threads, messages and runs in Convex.
|
|
2
|
+
// Public functions guard themselves (permission, module enabled, role view,
|
|
3
|
+
// human session, ownership) in the same transaction as the change. Internal
|
|
4
|
+
// functions are called only by the runner (convex/aiRunner.ts).
|
|
5
|
+
import { internalMutationGeneric, internalQueryGeneric, makeFunctionReference, mutationGeneric, queryGeneric } from "convex/server"
|
|
6
|
+
import { v } from "convex/values"
|
|
7
|
+
import { decide, requireHuman, toActorRef, type ActorRef, type UserActor } from "../core/access.js"
|
|
8
|
+
import { recordAudit } from "../core/audit.js"
|
|
9
|
+
import type { SystemContext } from "../core/context.js"
|
|
10
|
+
import { SystemError } from "../core/errors.js"
|
|
11
|
+
import { authorizeJob } from "../core/jobs.js"
|
|
12
|
+
import { toConvexError, type ConvexSystem, type DbCtx } from "../convex/functions.js"
|
|
13
|
+
import {
|
|
14
|
+
AI_AUDIT,
|
|
15
|
+
type AgentView,
|
|
16
|
+
type AiAgentDefinition,
|
|
17
|
+
type AiMessageId,
|
|
18
|
+
type AiOptions,
|
|
19
|
+
type AiRunId,
|
|
20
|
+
type AiThreadId,
|
|
21
|
+
type MessagePart,
|
|
22
|
+
type MessageStatus,
|
|
23
|
+
type MessageView,
|
|
24
|
+
type RunContext,
|
|
25
|
+
type RunStatus,
|
|
26
|
+
type ThreadStatus,
|
|
27
|
+
type ThreadView,
|
|
28
|
+
type ToolCallState,
|
|
29
|
+
type ToolExecutionResult,
|
|
30
|
+
toolCallState,
|
|
31
|
+
} from "./contract.js"
|
|
32
|
+
import { AiPermissions } from "./manifest.js"
|
|
33
|
+
import { executeAiTool } from "./tools.js"
|
|
34
|
+
|
|
35
|
+
export const AI_MESSAGE_MAX = 8000
|
|
36
|
+
export const AI_TITLE_MAX = 120
|
|
37
|
+
const AUTO_TITLE_LENGTH = 60
|
|
38
|
+
const PAGE_DEFAULT = 30
|
|
39
|
+
const PAGE_MAX = 100
|
|
40
|
+
|
|
41
|
+
// --- Rows & loosely typed db (the app schema contains `aiTables`) ---------------------
|
|
42
|
+
|
|
43
|
+
type ErrorInfo = { code: string; message: string }
|
|
44
|
+
type MessageErrorInfo = ErrorInfo & { operationId?: string }
|
|
45
|
+
|
|
46
|
+
interface ThreadDoc {
|
|
47
|
+
_id: AiThreadId
|
|
48
|
+
_creationTime: number
|
|
49
|
+
ownerId: string
|
|
50
|
+
title: string
|
|
51
|
+
agentId: string
|
|
52
|
+
status: ThreadStatus
|
|
53
|
+
archived: boolean
|
|
54
|
+
createdAt: number
|
|
55
|
+
updatedAt: number
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
interface MessageDoc {
|
|
59
|
+
_id: AiMessageId
|
|
60
|
+
_creationTime: number
|
|
61
|
+
threadId: AiThreadId
|
|
62
|
+
role: "user" | "assistant"
|
|
63
|
+
parts: MessagePart[]
|
|
64
|
+
status: MessageStatus
|
|
65
|
+
error?: MessageErrorInfo
|
|
66
|
+
runId?: AiRunId
|
|
67
|
+
createdAt: number
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
interface RunDoc {
|
|
71
|
+
_id: AiRunId
|
|
72
|
+
_creationTime: number
|
|
73
|
+
threadId: AiThreadId
|
|
74
|
+
messageId: AiMessageId
|
|
75
|
+
actor: ActorRef
|
|
76
|
+
agentId: string
|
|
77
|
+
model: string
|
|
78
|
+
status: RunStatus
|
|
79
|
+
stopRequested: boolean
|
|
80
|
+
operationId: string
|
|
81
|
+
error?: ErrorInfo
|
|
82
|
+
createdAt: number
|
|
83
|
+
updatedAt: number
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
type Docs = { aiThreads: ThreadDoc; aiMessages: MessageDoc; aiRuns: RunDoc }
|
|
87
|
+
type Table = keyof Docs
|
|
88
|
+
type IndexQ = { eq(field: string, value: unknown): IndexQ }
|
|
89
|
+
interface Ordered<D> {
|
|
90
|
+
take(n: number): Promise<D[]>
|
|
91
|
+
first(): Promise<D | null>
|
|
92
|
+
collect(): Promise<D[]>
|
|
93
|
+
paginate(o: { cursor: string | null; numItems: number }): Promise<{ page: D[]; continueCursor: string; isDone: boolean }>
|
|
94
|
+
}
|
|
95
|
+
interface Query<D> extends Ordered<D> {
|
|
96
|
+
order(o: "asc" | "desc"): Ordered<D>
|
|
97
|
+
}
|
|
98
|
+
interface Db {
|
|
99
|
+
query<T extends Table>(t: T): { withIndex(i: string, f: (q: IndexQ) => unknown): Query<Docs[T]> }
|
|
100
|
+
get<T extends Table>(t: T, id: string): Promise<Docs[T] | null>
|
|
101
|
+
insert<T extends Table>(t: T, doc: Omit<Docs[T], "_id" | "_creationTime">): Promise<Docs[T]["_id"]>
|
|
102
|
+
patch<T extends Table>(t: T, id: string, doc: Partial<Omit<Docs[T], "_id" | "_creationTime">>): Promise<void>
|
|
103
|
+
}
|
|
104
|
+
type Scheduler = { runAfter(ms: number, ref: unknown, args: Record<string, unknown>): Promise<unknown> }
|
|
105
|
+
const db = (ctx: { db: unknown }) => ctx.db as Db
|
|
106
|
+
|
|
107
|
+
// --- Views -------------------------------------------------------------------------------
|
|
108
|
+
|
|
109
|
+
const threadView = (t: ThreadDoc): ThreadView => ({ id: t._id, title: t.title, agentId: t.agentId, status: t.status, createdAt: t.createdAt, updatedAt: t.updatedAt })
|
|
110
|
+
|
|
111
|
+
const messageView = (m: MessageDoc): MessageView => ({
|
|
112
|
+
id: m._id,
|
|
113
|
+
role: m.role,
|
|
114
|
+
parts: m.parts,
|
|
115
|
+
status: m.status,
|
|
116
|
+
error: m.error ? { code: m.error.code, message: m.error.message, ...(m.error.operationId ? { operationId: m.error.operationId } : {}) } : null,
|
|
117
|
+
createdAt: m.createdAt,
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
const agentView = (a: AiAgentDefinition, tools: AiAgentDefinition["tools"]): AgentView => ({
|
|
121
|
+
id: a.id,
|
|
122
|
+
name: a.name,
|
|
123
|
+
description: a.description,
|
|
124
|
+
tools: tools.map((t) => ({ id: t.id, description: t.description, requiresApproval: t.requiresApproval ?? false })),
|
|
125
|
+
})
|
|
126
|
+
|
|
127
|
+
// --- Helpers -----------------------------------------------------------------------------
|
|
128
|
+
|
|
129
|
+
type FailCode = "NOT_FOUND" | "CONFLICT" | "VALIDATION" | "FORBIDDEN"
|
|
130
|
+
|
|
131
|
+
function fail(code: FailCode, reason: string, operationId: string): never {
|
|
132
|
+
throw toConvexError(new SystemError(code, undefined, { reason, operationId }), operationId)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const actorArgs = { apiKey: v.optional(v.string()), operationId: v.optional(v.string()) }
|
|
136
|
+
const ACTIVE_RUN: ReadonlySet<RunStatus> = new Set(["queued", "running", "waiting-approval"])
|
|
137
|
+
const finishStatus = v.union(v.literal("done"), v.literal("error"), v.literal("stopped"), v.literal("waiting-approval"))
|
|
138
|
+
const errorInfo = v.object({ code: v.string(), message: v.string() })
|
|
139
|
+
|
|
140
|
+
/** First line-ish of the first message, whitespace collapsed. */
|
|
141
|
+
const autoTitle = (text: string) => {
|
|
142
|
+
const flat = text.replace(/\s+/g, " ").trim()
|
|
143
|
+
return flat.length <= AUTO_TITLE_LENGTH ? flat : `${flat.slice(0, AUTO_TITLE_LENGTH - 1).trimEnd()}…`
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The AI module's Convex functions, bound to the app's system:
|
|
148
|
+
* // convex/ai.ts
|
|
149
|
+
* export const { listAgents, listThreads, …, _startRun, … } = defineAi(system, aiOptions).functions
|
|
150
|
+
* Names: AI_PUBLIC_FUNCTIONS + AI_INTERNAL_FUNCTIONS in contract.ts.
|
|
151
|
+
*/
|
|
152
|
+
export function defineAi(system: ConvexSystem, options: AiOptions) {
|
|
153
|
+
if (!options.agents.length) throw new Error("defineAi: at least one agent is required")
|
|
154
|
+
const agents = new Map(options.agents.map((a) => [a.id, a]))
|
|
155
|
+
const runnerRef = makeFunctionReference<"action">(`${options.runnerModule ?? "aiRunner"}:runAgent`)
|
|
156
|
+
|
|
157
|
+
/** Permission, module, role view, then human-only. */
|
|
158
|
+
async function guard(ctx: DbCtx, a: { apiKey?: string | undefined; operationId?: string | undefined }): Promise<{ sys: SystemContext; actor: UserActor }> {
|
|
159
|
+
const { sys, actor } = await system.requirePermission(ctx, AiPermissions.use, { apiKey: a.apiKey, operationId: a.operationId })
|
|
160
|
+
try {
|
|
161
|
+
return { sys, actor: requireHuman(sys, actor) }
|
|
162
|
+
} catch (e) {
|
|
163
|
+
throw toConvexError(e, sys.operationId)
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** A thread the caller owns. Missing and foreign threads look the same. */
|
|
168
|
+
async function ownThread(ctx: { db: unknown }, sys: SystemContext, actor: UserActor, threadId: string): Promise<ThreadDoc> {
|
|
169
|
+
const thread = await db(ctx).get("aiThreads", threadId)
|
|
170
|
+
if (!thread || thread.ownerId !== actor.userId) fail("NOT_FOUND", "thread_not_found", sys.operationId)
|
|
171
|
+
return thread!
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function schedule(ctx: { scheduler: unknown }, runId: AiRunId) {
|
|
175
|
+
return (ctx.scheduler as Scheduler).runAfter(0, runnerRef, { runId })
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** Closes a run: run, its assistant message and the thread. */
|
|
179
|
+
async function finish(ctx: { db: unknown }, run: RunDoc, status: "done" | "error" | "stopped" | "waiting-approval", error?: ErrorInfo) {
|
|
180
|
+
const now = Date.now()
|
|
181
|
+
await db(ctx).patch("aiRuns", run._id, { status, updatedAt: now, ...(error ? { error } : {}) })
|
|
182
|
+
const message = await db(ctx).get("aiMessages", run.messageId)
|
|
183
|
+
if (message) {
|
|
184
|
+
// Waiting for approval: the message is complete for now; its parts hold the pending call.
|
|
185
|
+
const msgStatus: MessageStatus = status === "waiting-approval" ? "done" : status
|
|
186
|
+
const patch: Partial<MessageDoc> = { status: msgStatus }
|
|
187
|
+
// The message carries the run's operationId so the UI can show it next to the error.
|
|
188
|
+
if (error) patch.error = { code: error.code, message: error.message, operationId: run.operationId }
|
|
189
|
+
if (status === "stopped") {
|
|
190
|
+
// Nobody can approve a stopped run's calls any more.
|
|
191
|
+
patch.parts = message.parts.map((p) => (p.type === "tool-call" && p.state === "awaiting-approval" ? { ...p, state: "rejected", error: "stopped" } : p))
|
|
192
|
+
}
|
|
193
|
+
await db(ctx).patch("aiMessages", message._id, patch)
|
|
194
|
+
}
|
|
195
|
+
await db(ctx).patch("aiThreads", run.threadId, { status: status === "waiting-approval" ? "waiting-approval" : "idle", updatedAt: now })
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/** Puts a run back in the queue and schedules the runner. */
|
|
199
|
+
async function resume(ctx: { db: unknown; scheduler: unknown }, run: RunDoc) {
|
|
200
|
+
const now = Date.now()
|
|
201
|
+
await db(ctx).patch("aiRuns", run._id, { status: "queued", updatedAt: now })
|
|
202
|
+
await db(ctx).patch("aiMessages", run.messageId, { status: "streaming" })
|
|
203
|
+
await db(ctx).patch("aiThreads", run.threadId, { status: "running", updatedAt: now })
|
|
204
|
+
await schedule(ctx, run._id)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function bindRun(ctx: DbCtx, run: RunDoc) {
|
|
208
|
+
return (await system.context(ctx, { operationId: run.operationId })).sys
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function updateMessageParts(ctx: { db: unknown }, messageId: string, fn: (parts: MessagePart[]) => MessagePart[] | null) {
|
|
212
|
+
const message = await db(ctx).get("aiMessages", messageId)
|
|
213
|
+
if (!message) throw toConvexError(new SystemError("NOT_FOUND", undefined, { reason: "message_not_found" }))
|
|
214
|
+
const parts = fn(message.parts)
|
|
215
|
+
if (parts) await db(ctx).patch("aiMessages", messageId, { parts })
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
const functions = {
|
|
219
|
+
// --- Public ---------------------------------------------------------------------------
|
|
220
|
+
|
|
221
|
+
listAgents: queryGeneric({
|
|
222
|
+
args: { ...actorArgs },
|
|
223
|
+
handler: async (ctx, a): Promise<AgentView[]> => {
|
|
224
|
+
const { sys, actor } = await guard(ctx, a)
|
|
225
|
+
const out: AgentView[] = []
|
|
226
|
+
for (const agent of options.agents) {
|
|
227
|
+
const tools = []
|
|
228
|
+
for (const tool of agent.tools) if ((await decide(sys, actor, tool.permission)).allowed) tools.push(tool)
|
|
229
|
+
out.push(agentView(agent, tools))
|
|
230
|
+
}
|
|
231
|
+
return out
|
|
232
|
+
},
|
|
233
|
+
}),
|
|
234
|
+
|
|
235
|
+
listThreads: queryGeneric({
|
|
236
|
+
args: { ...actorArgs, cursor: v.optional(v.union(v.string(), v.null())), limit: v.optional(v.number()) },
|
|
237
|
+
handler: async (ctx, a): Promise<{ items: ThreadView[]; cursor: string | null; done: boolean }> => {
|
|
238
|
+
const { actor } = await guard(ctx, a)
|
|
239
|
+
const numItems = Math.min(Math.max(1, Math.floor(a.limit ?? PAGE_DEFAULT)), PAGE_MAX)
|
|
240
|
+
const res = await db(ctx)
|
|
241
|
+
.query("aiThreads")
|
|
242
|
+
.withIndex("by_owner", (q) => q.eq("ownerId", actor.userId).eq("archived", false))
|
|
243
|
+
.order("desc")
|
|
244
|
+
.paginate({ cursor: a.cursor ?? null, numItems })
|
|
245
|
+
return { items: res.page.map(threadView), cursor: res.isDone ? null : res.continueCursor, done: res.isDone }
|
|
246
|
+
},
|
|
247
|
+
}),
|
|
248
|
+
|
|
249
|
+
getThread: queryGeneric({
|
|
250
|
+
args: { ...actorArgs, threadId: v.id("aiThreads") },
|
|
251
|
+
handler: async (ctx, a): Promise<ThreadView> => {
|
|
252
|
+
const { sys, actor } = await guard(ctx, a)
|
|
253
|
+
return threadView(await ownThread(ctx, sys, actor, a.threadId))
|
|
254
|
+
},
|
|
255
|
+
}),
|
|
256
|
+
|
|
257
|
+
listMessages: queryGeneric({
|
|
258
|
+
args: { ...actorArgs, threadId: v.id("aiThreads") },
|
|
259
|
+
handler: async (ctx, a): Promise<MessageView[]> => {
|
|
260
|
+
const { sys, actor } = await guard(ctx, a)
|
|
261
|
+
await ownThread(ctx, sys, actor, a.threadId)
|
|
262
|
+
const rows = await db(ctx)
|
|
263
|
+
.query("aiMessages")
|
|
264
|
+
.withIndex("by_thread", (q) => q.eq("threadId", a.threadId))
|
|
265
|
+
.order("asc")
|
|
266
|
+
.collect()
|
|
267
|
+
return rows.map(messageView)
|
|
268
|
+
},
|
|
269
|
+
}),
|
|
270
|
+
|
|
271
|
+
createThread: mutationGeneric({
|
|
272
|
+
args: { ...actorArgs, agentId: v.optional(v.string()), title: v.optional(v.string()) },
|
|
273
|
+
handler: async (ctx, a): Promise<{ threadId: AiThreadId }> => {
|
|
274
|
+
const { sys, actor } = await guard(ctx, a)
|
|
275
|
+
const agentId = a.agentId ?? options.agents[0]!.id
|
|
276
|
+
if (!agents.has(agentId)) fail("VALIDATION", "unknown_agent", sys.operationId)
|
|
277
|
+
const title = (a.title ?? "").trim()
|
|
278
|
+
if (title.length > AI_TITLE_MAX) fail("VALIDATION", "title_too_long", sys.operationId)
|
|
279
|
+
const now = Date.now()
|
|
280
|
+
const threadId = await db(ctx).insert("aiThreads", { ownerId: actor.userId, title, agentId, status: "idle", archived: false, createdAt: now, updatedAt: now })
|
|
281
|
+
await recordAudit(sys, actor, { action: AI_AUDIT.threadCreate, targetType: "aiThread", targetId: threadId, changes: { agentId } })
|
|
282
|
+
return { threadId }
|
|
283
|
+
},
|
|
284
|
+
}),
|
|
285
|
+
|
|
286
|
+
sendMessage: mutationGeneric({
|
|
287
|
+
args: { ...actorArgs, threadId: v.id("aiThreads"), text: v.string() },
|
|
288
|
+
handler: async (ctx, a): Promise<{ messageId: AiMessageId; runId: AiRunId }> => {
|
|
289
|
+
const { sys, actor } = await guard(ctx, a)
|
|
290
|
+
const thread = await ownThread(ctx, sys, actor, a.threadId)
|
|
291
|
+
const text = a.text.trim()
|
|
292
|
+
if (!text) fail("VALIDATION", "empty_message", sys.operationId)
|
|
293
|
+
if (text.length > AI_MESSAGE_MAX) fail("VALIDATION", "message_too_long", sys.operationId)
|
|
294
|
+
if (thread.status !== "idle") fail("CONFLICT", "thread_busy", sys.operationId)
|
|
295
|
+
const agent = agents.get(thread.agentId)
|
|
296
|
+
if (!agent) fail("VALIDATION", "unknown_agent", sys.operationId)
|
|
297
|
+
|
|
298
|
+
const now = Date.now()
|
|
299
|
+
const messageId = await db(ctx).insert("aiMessages", { threadId: thread._id, role: "user", parts: [{ type: "text", text }], status: "done", createdAt: now })
|
|
300
|
+
const assistantId = await db(ctx).insert("aiMessages", { threadId: thread._id, role: "assistant", parts: [], status: "streaming", createdAt: now + 1 })
|
|
301
|
+
const runId = await db(ctx).insert("aiRuns", {
|
|
302
|
+
threadId: thread._id,
|
|
303
|
+
messageId: assistantId,
|
|
304
|
+
actor: toActorRef(actor),
|
|
305
|
+
agentId: agent!.id,
|
|
306
|
+
model: agent!.model ?? process.env.AI_MODEL ?? options.defaultModel,
|
|
307
|
+
status: "queued",
|
|
308
|
+
stopRequested: false,
|
|
309
|
+
operationId: sys.operationId,
|
|
310
|
+
createdAt: now,
|
|
311
|
+
updatedAt: now,
|
|
312
|
+
})
|
|
313
|
+
await db(ctx).patch("aiMessages", assistantId, { runId })
|
|
314
|
+
await db(ctx).patch("aiThreads", thread._id, { status: "running", updatedAt: now, ...(thread.title ? {} : { title: autoTitle(text) }) })
|
|
315
|
+
await schedule(ctx, runId)
|
|
316
|
+
await recordAudit(sys, actor, { action: AI_AUDIT.messageSend, targetType: "aiThread", targetId: thread._id, changes: { messageId, runId, length: text.length } })
|
|
317
|
+
return { messageId, runId }
|
|
318
|
+
},
|
|
319
|
+
}),
|
|
320
|
+
|
|
321
|
+
stopRun: mutationGeneric({
|
|
322
|
+
args: { ...actorArgs, threadId: v.id("aiThreads") },
|
|
323
|
+
handler: async (ctx, a): Promise<{ stopped: boolean }> => {
|
|
324
|
+
const { sys, actor } = await guard(ctx, a)
|
|
325
|
+
const thread = await ownThread(ctx, sys, actor, a.threadId)
|
|
326
|
+
const run = await db(ctx)
|
|
327
|
+
.query("aiRuns")
|
|
328
|
+
.withIndex("by_thread", (q) => q.eq("threadId", thread._id))
|
|
329
|
+
.order("desc")
|
|
330
|
+
.first()
|
|
331
|
+
if (!run || !ACTIVE_RUN.has(run.status)) return { stopped: false }
|
|
332
|
+
await db(ctx).patch("aiRuns", run._id, { stopRequested: true, updatedAt: Date.now() })
|
|
333
|
+
// Nothing is executing a queued or approval-waiting run: close it now.
|
|
334
|
+
// A running one is closed by the runner when it sees `stopRequested`.
|
|
335
|
+
if (run.status !== "running") await finish(ctx, run, "stopped")
|
|
336
|
+
await recordAudit(sys, actor, { action: AI_AUDIT.runStop, targetType: "aiRun", targetId: run._id, changes: { threadId: thread._id, status: run.status } })
|
|
337
|
+
return { stopped: true }
|
|
338
|
+
},
|
|
339
|
+
}),
|
|
340
|
+
|
|
341
|
+
respondToToolCall: mutationGeneric({
|
|
342
|
+
args: { ...actorArgs, messageId: v.id("aiMessages"), toolCallId: v.string(), approve: v.boolean() },
|
|
343
|
+
handler: async (ctx, a): Promise<{ runId: AiRunId }> => {
|
|
344
|
+
const { sys, actor } = await guard(ctx, a)
|
|
345
|
+
const message = await db(ctx).get("aiMessages", a.messageId)
|
|
346
|
+
if (!message) fail("NOT_FOUND", "message_not_found", sys.operationId)
|
|
347
|
+
await ownThread(ctx, sys, actor, message!.threadId)
|
|
348
|
+
const run = message!.runId ? await db(ctx).get("aiRuns", message!.runId) : null
|
|
349
|
+
if (!run) fail("NOT_FOUND", "run_not_found", sys.operationId)
|
|
350
|
+
const part = message!.parts.find((p) => p.type === "tool-call" && p.toolCallId === a.toolCallId)
|
|
351
|
+
if (part?.type !== "tool-call") fail("NOT_FOUND", "tool_call_not_found", sys.operationId)
|
|
352
|
+
if (part!.state !== "awaiting-approval" || run!.stopRequested) fail("CONFLICT", "tool_call_not_awaiting_approval", sys.operationId)
|
|
353
|
+
|
|
354
|
+
const state: ToolCallState = a.approve ? "approved" : "rejected"
|
|
355
|
+
const parts = message!.parts.map((p) => (p.type === "tool-call" && p.toolCallId === a.toolCallId ? { ...p, state } : p))
|
|
356
|
+
await db(ctx).patch("aiMessages", message!._id, { parts })
|
|
357
|
+
await recordAudit(sys, actor, {
|
|
358
|
+
action: a.approve ? AI_AUDIT.toolApprove : AI_AUDIT.toolReject,
|
|
359
|
+
targetType: "aiToolCall",
|
|
360
|
+
targetId: a.toolCallId,
|
|
361
|
+
changes: { runId: run!._id, toolName: (part as { toolName: string }).toolName },
|
|
362
|
+
})
|
|
363
|
+
const pending = parts.some((p) => p.type === "tool-call" && p.state === "awaiting-approval")
|
|
364
|
+
// Resume only once the runner has parked the run; if it is still
|
|
365
|
+
// finishing, `_finishRun("waiting-approval")` resumes it instead.
|
|
366
|
+
if (!pending && run!.status === "waiting-approval") await resume(ctx, run!)
|
|
367
|
+
return { runId: run!._id }
|
|
368
|
+
},
|
|
369
|
+
}),
|
|
370
|
+
|
|
371
|
+
renameThread: mutationGeneric({
|
|
372
|
+
args: { ...actorArgs, threadId: v.id("aiThreads"), title: v.string() },
|
|
373
|
+
handler: async (ctx, a): Promise<null> => {
|
|
374
|
+
const { sys, actor } = await guard(ctx, a)
|
|
375
|
+
const thread = await ownThread(ctx, sys, actor, a.threadId)
|
|
376
|
+
const title = a.title.trim()
|
|
377
|
+
if (!title || title.length > AI_TITLE_MAX) fail("VALIDATION", "invalid_title", sys.operationId)
|
|
378
|
+
await db(ctx).patch("aiThreads", thread._id, { title, updatedAt: Date.now() })
|
|
379
|
+
return null
|
|
380
|
+
},
|
|
381
|
+
}),
|
|
382
|
+
|
|
383
|
+
archiveThread: mutationGeneric({
|
|
384
|
+
args: { ...actorArgs, threadId: v.id("aiThreads") },
|
|
385
|
+
handler: async (ctx, a): Promise<null> => {
|
|
386
|
+
const { sys, actor } = await guard(ctx, a)
|
|
387
|
+
const thread = await ownThread(ctx, sys, actor, a.threadId)
|
|
388
|
+
await db(ctx).patch("aiThreads", thread._id, { archived: true, updatedAt: Date.now() })
|
|
389
|
+
return null
|
|
390
|
+
},
|
|
391
|
+
}),
|
|
392
|
+
|
|
393
|
+
// --- Internal (runner only) -------------------------------------------------------------
|
|
394
|
+
|
|
395
|
+
_startRun: internalMutationGeneric({
|
|
396
|
+
args: { runId: v.id("aiRuns") },
|
|
397
|
+
handler: async (ctx, a): Promise<RunContext | null> => {
|
|
398
|
+
const run = await db(ctx).get("aiRuns", a.runId)
|
|
399
|
+
if (!run) return null
|
|
400
|
+
if (run.stopRequested) {
|
|
401
|
+
if (ACTIVE_RUN.has(run.status)) await finish(ctx, run, "stopped")
|
|
402
|
+
return null
|
|
403
|
+
}
|
|
404
|
+
// Only a queued run starts: a duplicate schedule is a no-op.
|
|
405
|
+
if (run.status !== "queued") return null
|
|
406
|
+
const sys = await bindRun(ctx, run)
|
|
407
|
+
const agent = agents.get(run.agentId)
|
|
408
|
+
if (!agent) {
|
|
409
|
+
await finish(ctx, run, "error", { code: "VALIDATION", message: "unknown_agent" })
|
|
410
|
+
return null
|
|
411
|
+
}
|
|
412
|
+
// The actor may have lost the chat itself since the message was sent.
|
|
413
|
+
if (!(await authorizeJob(sys, run.actor, AiPermissions.use))) {
|
|
414
|
+
await finish(ctx, run, "error", { code: "FORBIDDEN", message: "chat_not_allowed" })
|
|
415
|
+
return null
|
|
416
|
+
}
|
|
417
|
+
const allowedToolIds: string[] = []
|
|
418
|
+
for (const tool of agent.tools) if (await authorizeJob(sys, run.actor, tool.permission)) allowedToolIds.push(tool.id)
|
|
419
|
+
const now = Date.now()
|
|
420
|
+
await db(ctx).patch("aiRuns", run._id, { status: "running", updatedAt: now })
|
|
421
|
+
await db(ctx).patch("aiThreads", run.threadId, { status: "running", updatedAt: now })
|
|
422
|
+
const history = await db(ctx)
|
|
423
|
+
.query("aiMessages")
|
|
424
|
+
.withIndex("by_thread", (q) => q.eq("threadId", run.threadId))
|
|
425
|
+
.order("asc")
|
|
426
|
+
.collect()
|
|
427
|
+
return {
|
|
428
|
+
runId: run._id,
|
|
429
|
+
threadId: run.threadId,
|
|
430
|
+
messageId: run.messageId,
|
|
431
|
+
agentId: run.agentId,
|
|
432
|
+
model: run.model,
|
|
433
|
+
actor: run.actor,
|
|
434
|
+
operationId: run.operationId,
|
|
435
|
+
history: history.map(messageView),
|
|
436
|
+
allowedToolIds,
|
|
437
|
+
}
|
|
438
|
+
},
|
|
439
|
+
}),
|
|
440
|
+
|
|
441
|
+
_appendText: internalMutationGeneric({
|
|
442
|
+
args: { messageId: v.id("aiMessages"), text: v.string() },
|
|
443
|
+
handler: async (ctx, a): Promise<null> => {
|
|
444
|
+
if (!a.text) return null
|
|
445
|
+
await updateMessageParts(ctx, a.messageId, (parts) => {
|
|
446
|
+
const last = parts.at(-1)
|
|
447
|
+
if (last?.type === "text") return [...parts.slice(0, -1), { type: "text", text: last.text + a.text }]
|
|
448
|
+
return [...parts, { type: "text", text: a.text }]
|
|
449
|
+
})
|
|
450
|
+
return null
|
|
451
|
+
},
|
|
452
|
+
}),
|
|
453
|
+
|
|
454
|
+
_addToolCall: internalMutationGeneric({
|
|
455
|
+
args: { messageId: v.id("aiMessages"), toolCallId: v.string(), toolName: v.string(), args: v.any(), state: toolCallState },
|
|
456
|
+
handler: async (ctx, a): Promise<null> => {
|
|
457
|
+
await updateMessageParts(ctx, a.messageId, (parts) => {
|
|
458
|
+
// Idempotent: a retried runner step must not duplicate the call.
|
|
459
|
+
if (parts.some((p) => p.type === "tool-call" && p.toolCallId === a.toolCallId)) return null
|
|
460
|
+
return [...parts, { type: "tool-call", toolCallId: a.toolCallId, toolName: a.toolName, args: a.args, state: a.state }]
|
|
461
|
+
})
|
|
462
|
+
return null
|
|
463
|
+
},
|
|
464
|
+
}),
|
|
465
|
+
|
|
466
|
+
_setToolCallState: internalMutationGeneric({
|
|
467
|
+
args: { messageId: v.id("aiMessages"), toolCallId: v.string(), state: toolCallState, result: v.optional(v.any()), error: v.optional(v.string()) },
|
|
468
|
+
handler: async (ctx, a): Promise<null> => {
|
|
469
|
+
await updateMessageParts(ctx, a.messageId, (parts) => {
|
|
470
|
+
if (!parts.some((p) => p.type === "tool-call" && p.toolCallId === a.toolCallId)) {
|
|
471
|
+
throw toConvexError(new SystemError("NOT_FOUND", undefined, { reason: "tool_call_not_found" }))
|
|
472
|
+
}
|
|
473
|
+
return parts.map((p) => {
|
|
474
|
+
if (p.type !== "tool-call" || p.toolCallId !== a.toolCallId) return p
|
|
475
|
+
const next = { ...p, state: a.state }
|
|
476
|
+
if (a.result !== undefined) next.result = a.result
|
|
477
|
+
if (a.error !== undefined) next.error = a.error
|
|
478
|
+
return next
|
|
479
|
+
})
|
|
480
|
+
})
|
|
481
|
+
return null
|
|
482
|
+
},
|
|
483
|
+
}),
|
|
484
|
+
|
|
485
|
+
_executeTool: internalMutationGeneric({
|
|
486
|
+
args: { runId: v.id("aiRuns"), toolCallId: v.string() },
|
|
487
|
+
handler: async (ctx, a): Promise<ToolExecutionResult> => {
|
|
488
|
+
const run = await db(ctx).get("aiRuns", a.runId)
|
|
489
|
+
if (!run) return { ok: false, code: "NOT_FOUND", message: "run_not_found" }
|
|
490
|
+
const message = await db(ctx).get("aiMessages", run.messageId)
|
|
491
|
+
const part = message?.parts.find((p) => p.type === "tool-call" && p.toolCallId === a.toolCallId)
|
|
492
|
+
if (part?.type !== "tool-call") return { ok: false, code: "NOT_FOUND", message: "tool_call_not_found" }
|
|
493
|
+
const tool = agents.get(run.agentId)?.tools.find((t) => t.id === part.toolName)
|
|
494
|
+
if (!tool) return { ok: false, code: "VALIDATION", message: "unknown_tool" }
|
|
495
|
+
// Defence in depth: an approval-gated tool never runs before the user approved it.
|
|
496
|
+
if (tool.requiresApproval && part.state !== "approved" && part.state !== "running") return { ok: false, code: "FORBIDDEN", message: "approval_required" }
|
|
497
|
+
const sys = await bindRun(ctx, run)
|
|
498
|
+
return executeAiTool({ db: ctx.db, sys, actorRef: run.actor, tool, args: part.args, toolCallId: a.toolCallId })
|
|
499
|
+
},
|
|
500
|
+
}),
|
|
501
|
+
|
|
502
|
+
_finishRun: internalMutationGeneric({
|
|
503
|
+
args: { runId: v.id("aiRuns"), status: finishStatus, error: v.optional(errorInfo) },
|
|
504
|
+
handler: async (ctx, a): Promise<null> => {
|
|
505
|
+
const run = await db(ctx).get("aiRuns", a.runId)
|
|
506
|
+
if (!run) return null
|
|
507
|
+
// Stop wins over whatever the runner reports, except an error.
|
|
508
|
+
const status = run.stopRequested && a.status !== "error" ? "stopped" : a.status
|
|
509
|
+
if (status === "waiting-approval") {
|
|
510
|
+
const message = await db(ctx).get("aiMessages", run.messageId)
|
|
511
|
+
const pending = message?.parts.some((p) => p.type === "tool-call" && p.state === "awaiting-approval") ?? false
|
|
512
|
+
// The user already answered every call while the runner was finishing: go on.
|
|
513
|
+
if (!pending) {
|
|
514
|
+
await resume(ctx, run)
|
|
515
|
+
return null
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
await finish(ctx, run, status, a.error)
|
|
519
|
+
return null
|
|
520
|
+
},
|
|
521
|
+
}),
|
|
522
|
+
|
|
523
|
+
_isStopRequested: internalQueryGeneric({
|
|
524
|
+
args: { runId: v.id("aiRuns") },
|
|
525
|
+
handler: async (ctx, a): Promise<boolean> => {
|
|
526
|
+
const run = await db(ctx).get("aiRuns", a.runId)
|
|
527
|
+
return !run || run.stopRequested
|
|
528
|
+
},
|
|
529
|
+
}),
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
return { functions }
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
export type AiSystem = ReturnType<typeof defineAi>
|
package/src/ai/card.ts
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// `@digimuza/system-core/ai/card`: the browser side of `ai.show_card`. No
|
|
2
|
+
// dependencies, safe for client bundles. See display.ts for the security model:
|
|
3
|
+
// render ONLY in <iframe sandbox="allow-scripts" srcDoc={cardDocument(...)}>.
|
|
4
|
+
|
|
5
|
+
export const AI_CARD_HTML_MAX = 60_000
|
|
6
|
+
export const AI_CARD_TITLE_MAX = 120
|
|
7
|
+
/** `postMessage` type the card's resize script sends to the parent. */
|
|
8
|
+
export const AI_CARD_RESIZE_MESSAGE = "ai-card:height"
|
|
9
|
+
|
|
10
|
+
/** Theme passed into the card: CSS custom properties (name without `--`) → value. */
|
|
11
|
+
export type CardTheme = Record<string, string>
|
|
12
|
+
|
|
13
|
+
/** Theme variables a card may use (the app copies their computed values). */
|
|
14
|
+
export const AI_CARD_THEME_VARS = [
|
|
15
|
+
"background",
|
|
16
|
+
"foreground",
|
|
17
|
+
"card",
|
|
18
|
+
"card-foreground",
|
|
19
|
+
"muted",
|
|
20
|
+
"muted-foreground",
|
|
21
|
+
"border",
|
|
22
|
+
"primary",
|
|
23
|
+
"primary-foreground",
|
|
24
|
+
"secondary",
|
|
25
|
+
"secondary-foreground",
|
|
26
|
+
"accent",
|
|
27
|
+
"accent-foreground",
|
|
28
|
+
"destructive",
|
|
29
|
+
"radius",
|
|
30
|
+
"chart-1",
|
|
31
|
+
"chart-2",
|
|
32
|
+
"chart-3",
|
|
33
|
+
"chart-4",
|
|
34
|
+
"chart-5",
|
|
35
|
+
] as const
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The CSP of every card: inline styles and scripts only, images from data: only,
|
|
39
|
+
* no network (connect/fetch/fonts/frames/forms), no base-uri tricks.
|
|
40
|
+
*/
|
|
41
|
+
export const AI_CARD_CSP = [
|
|
42
|
+
"default-src 'none'",
|
|
43
|
+
"style-src 'unsafe-inline'",
|
|
44
|
+
"script-src 'unsafe-inline'",
|
|
45
|
+
"img-src data:",
|
|
46
|
+
"font-src 'none'",
|
|
47
|
+
"connect-src 'none'",
|
|
48
|
+
"media-src 'none'",
|
|
49
|
+
"frame-src 'none'",
|
|
50
|
+
"form-action 'none'",
|
|
51
|
+
"base-uri 'none'",
|
|
52
|
+
].join("; ")
|
|
53
|
+
|
|
54
|
+
const safeCssValue = (v: string) => v.replace(/[<>{};\\]/g, "")
|
|
55
|
+
const safeVarName = (k: string) => /^[a-z][a-z0-9-]*$/.test(k)
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The full `srcdoc` for a card iframe: CSP meta first, theme variables, base
|
|
59
|
+
* styles, the model's HTML, and a resize script that posts the content height
|
|
60
|
+
* to the parent (`{ type: AI_CARD_RESIZE_MESSAGE, id, height }`).
|
|
61
|
+
* Render it ONLY in `<iframe sandbox="allow-scripts" srcDoc={...}>`.
|
|
62
|
+
*/
|
|
63
|
+
export function cardDocument(options: { html: string; id: string; theme?: CardTheme; dark?: boolean; fontFamily?: string }): string {
|
|
64
|
+
const vars = Object.entries(options.theme ?? {})
|
|
65
|
+
.filter(([k]) => safeVarName(k))
|
|
66
|
+
.map(([k, v]) => `--${k}:${safeCssValue(v)};`)
|
|
67
|
+
.join("")
|
|
68
|
+
const font = safeCssValue(options.fontFamily ?? 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif')
|
|
69
|
+
const id = JSON.stringify(String(options.id).replace(/[^A-Za-z0-9_-]/g, "").slice(0, 100))
|
|
70
|
+
return `<!doctype html><html${options.dark ? ' class="dark"' : ""}><head><meta charset="utf-8">
|
|
71
|
+
<meta http-equiv="Content-Security-Policy" content="${AI_CARD_CSP}">
|
|
72
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
73
|
+
<style>
|
|
74
|
+
:root{${vars}color-scheme:${options.dark ? "dark" : "light"};}
|
|
75
|
+
*,*::before,*::after{box-sizing:border-box;}
|
|
76
|
+
html,body{margin:0;padding:0;background:transparent;color:var(--foreground);font-family:${font};font-size:14px;line-height:1.5;-webkit-font-smoothing:antialiased;}
|
|
77
|
+
body{padding:16px;overflow-wrap:anywhere;}
|
|
78
|
+
h1,h2,h3,h4{margin:0 0 .5em;line-height:1.25;font-weight:600;text-wrap:balance;}
|
|
79
|
+
h1{font-size:1.25rem;}h2{font-size:1.125rem;}h3{font-size:1rem;}
|
|
80
|
+
p{margin:0 0 .75em;}p:last-child{margin-bottom:0;}
|
|
81
|
+
table{width:100%;border-collapse:collapse;font-variant-numeric:tabular-nums;}
|
|
82
|
+
th,td{padding:6px 8px;border-bottom:1px solid var(--border);text-align:left;}
|
|
83
|
+
th{color:var(--muted-foreground);font-weight:500;font-size:.85em;}
|
|
84
|
+
a{color:var(--primary);pointer-events:none;}
|
|
85
|
+
svg{max-width:100%;height:auto;}
|
|
86
|
+
img{max-width:100%;}
|
|
87
|
+
</style></head><body>${options.html}
|
|
88
|
+
<script>(function(){var id=${id};var last=0;function send(){var h=Math.ceil(document.documentElement.getBoundingClientRect().height);if(h!==last){last=h;parent.postMessage({type:${JSON.stringify(AI_CARD_RESIZE_MESSAGE)},id:id,height:h},"*");}}
|
|
89
|
+
new ResizeObserver(send).observe(document.documentElement);window.addEventListener("load",send);send();})();</script>
|
|
90
|
+
</body></html>`
|
|
91
|
+
}
|