@frankieai/core 0.2.4 → 0.2.6
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/dist/index.cjs +749 -127
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +410 -5
- package/dist/index.d.ts +410 -5
- package/dist/index.js +726 -126
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -21,6 +21,9 @@ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: tru
|
|
|
21
21
|
var index_exports = {};
|
|
22
22
|
__export(index_exports, {
|
|
23
23
|
FIREWORKS_MODELS: () => FIREWORKS_MODELS,
|
|
24
|
+
SKILL_TOOL_NAME: () => SKILL_TOOL_NAME,
|
|
25
|
+
STANDARD_AGENT_IDS: () => STANDARD_AGENT_IDS,
|
|
26
|
+
STARTER_PROMPT_ICONS: () => STARTER_PROMPT_ICONS,
|
|
24
27
|
activeSkills: () => activeSkills,
|
|
25
28
|
agentConfigSchema: () => agentConfigSchema,
|
|
26
29
|
apiCapabilitySchema: () => apiCapabilitySchema,
|
|
@@ -39,14 +42,32 @@ __export(index_exports, {
|
|
|
39
42
|
evaluateToolPolicy: () => evaluateToolPolicy,
|
|
40
43
|
featuresConfigSchema: () => featuresConfigSchema,
|
|
41
44
|
frankieConfigSchema: () => frankieConfigSchema,
|
|
45
|
+
inferTheme: () => inferTheme,
|
|
46
|
+
insertImport: () => insertImport,
|
|
47
|
+
installAgentIds: () => installAgentIds,
|
|
48
|
+
installBranch: () => installBranch,
|
|
49
|
+
installTitle: () => installTitle,
|
|
50
|
+
knowledgeBaseSchema: () => knowledgeBaseSchema,
|
|
51
|
+
knowledgeSourceSchema: () => knowledgeSourceSchema,
|
|
42
52
|
limitsConfigSchema: () => limitsConfigSchema,
|
|
43
53
|
matchPath: () => matchPath,
|
|
54
|
+
matchesAnyPagePattern: () => matchesAnyPagePattern,
|
|
55
|
+
matchesInstallBranch: () => matchesInstallBranch,
|
|
56
|
+
matchesPagePattern: () => matchesPagePattern,
|
|
44
57
|
mcpServerSchema: () => mcpServerSchema,
|
|
45
58
|
modelChoiceSchema: () => modelChoiceSchema,
|
|
46
59
|
modelConfigSchema: () => modelConfigSchema,
|
|
60
|
+
moduleImporter: () => moduleImporter,
|
|
61
|
+
mountWidget: () => mountWidget,
|
|
62
|
+
normalizeColor: () => normalizeColor,
|
|
47
63
|
operationId: () => operationId,
|
|
48
64
|
parseFrankieConfig: () => parseFrankieConfig,
|
|
65
|
+
personaEnvKey: () => personaEnvKey,
|
|
66
|
+
personaProfileSchema: () => personaProfileSchema,
|
|
67
|
+
plainText: () => plainText,
|
|
49
68
|
planHostedInstall: () => planHostedInstall,
|
|
69
|
+
renderJson: () => renderJson,
|
|
70
|
+
resolvePersonaConfig: () => resolvePersonaConfig,
|
|
50
71
|
sdkUpdateWorkflow: () => sdkUpdateWorkflow,
|
|
51
72
|
skillSchema: () => skillSchema,
|
|
52
73
|
starterPromptSchema: () => starterPromptSchema,
|
|
@@ -55,7 +76,8 @@ __export(index_exports, {
|
|
|
55
76
|
toolDescriptions: () => toolDescriptions,
|
|
56
77
|
toolPolicySchema: () => toolPolicySchema,
|
|
57
78
|
validateApiDocument: () => validateApiDocument,
|
|
58
|
-
widgetThemeSchema: () => widgetThemeSchema
|
|
79
|
+
widgetThemeSchema: () => widgetThemeSchema,
|
|
80
|
+
withDependencies: () => withDependencies
|
|
59
81
|
});
|
|
60
82
|
module.exports = __toCommonJS(index_exports);
|
|
61
83
|
|
|
@@ -87,12 +109,31 @@ function matches(toolName, pattern) {
|
|
|
87
109
|
if (toolName === pattern) return true;
|
|
88
110
|
return toolName.startsWith(`${pattern}__`);
|
|
89
111
|
}
|
|
112
|
+
var SKILL_TOOL_NAME = "frankie__use_skill";
|
|
90
113
|
function describeTool(toolName, input) {
|
|
114
|
+
if (toolName === SKILL_TOOL_NAME) {
|
|
115
|
+
const skill = typeof input === "object" && input !== null ? input["skill"] : void 0;
|
|
116
|
+
return typeof skill === "string" && skill.trim() ? `Using skill: ${skill.trim()}` : "Using a skill";
|
|
117
|
+
}
|
|
118
|
+
const builtIn = /^frankie__([a-z0-9]+)__(.+)$/.exec(toolName);
|
|
119
|
+
if (builtIn) {
|
|
120
|
+
const [, group, action] = builtIn;
|
|
121
|
+
if (group === "docs") return "Search the docs";
|
|
122
|
+
if (group === "escalate") return "Escalate to a person";
|
|
123
|
+
return humanise(action ?? toolName);
|
|
124
|
+
}
|
|
91
125
|
const mcp = /^mcp__([^_]+(?:_[^_]+)*)__(.+)$/.exec(toolName);
|
|
92
126
|
if (mcp) {
|
|
93
127
|
const [, server, tool] = mcp;
|
|
94
128
|
return `${humanise(tool ?? toolName)} \xB7 ${server}`;
|
|
95
129
|
}
|
|
130
|
+
if (toolName.startsWith("api__")) {
|
|
131
|
+
const rest = toolName.slice("api__".length);
|
|
132
|
+
const sep = rest.indexOf("__");
|
|
133
|
+
const operation = (sep >= 0 ? rest.slice(sep + 2) : rest).replace(/__+/g, "_");
|
|
134
|
+
const verb = /^(get|post|put|patch|delete|head|options)_(.+)$/i.exec(operation);
|
|
135
|
+
return verb ? `${verb[1].toUpperCase()} ${humanise(verb[2])}` : humanise(operation);
|
|
136
|
+
}
|
|
96
137
|
const inputRecord = typeof input === "object" && input !== null ? input : {};
|
|
97
138
|
const hint = inputRecord["path"] ?? inputRecord["url"] ?? inputRecord["query"] ?? inputRecord["command"];
|
|
98
139
|
return typeof hint === "string" ? `${humanise(toolName)}: ${truncate(hint, 60)}` : humanise(toolName);
|
|
@@ -132,16 +173,21 @@ function toolDescriptions(config, toolIds) {
|
|
|
132
173
|
for (const source of config.apis) {
|
|
133
174
|
for (const capability of source.capabilities) {
|
|
134
175
|
const id = `api__${source.name}__${capability.operationId.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 48)}`;
|
|
135
|
-
if (wanted.has(id))
|
|
176
|
+
if (wanted.has(id)) {
|
|
177
|
+
found.set(id, {
|
|
178
|
+
description: `${capability.method} ${capability.path}` + (capability.summary ? ` \u2014 ${capability.summary}` : ""),
|
|
179
|
+
...capability.inputs ? { inputs: capability.inputs } : {}
|
|
180
|
+
});
|
|
181
|
+
}
|
|
136
182
|
}
|
|
137
183
|
}
|
|
138
184
|
for (const source of config.mcpServers) {
|
|
139
185
|
for (const tool of source.discoveredTools) {
|
|
140
186
|
const id = `mcp__${source.name}__${tool.name}`;
|
|
141
|
-
if (wanted.has(id)) found.set(id, tool.description || tool.name);
|
|
187
|
+
if (wanted.has(id)) found.set(id, { description: tool.description || tool.name, ...tool.inputs ? { inputs: tool.inputs } : {} });
|
|
142
188
|
}
|
|
143
189
|
}
|
|
144
|
-
return toolIds.map((id) => ({ id,
|
|
190
|
+
return toolIds.map((id) => ({ id, ...found.get(id) ?? { description: id } }));
|
|
145
191
|
}
|
|
146
192
|
function suggestedPromptsFromSkills(config) {
|
|
147
193
|
const seen = /* @__PURE__ */ new Set();
|
|
@@ -179,6 +225,8 @@ function draftSkill(goal, tools) {
|
|
|
179
225
|
"",
|
|
180
226
|
"Required inputs: note what each tool below needs, and ask the user for anything missing before calling it.",
|
|
181
227
|
"",
|
|
228
|
+
"Quality bar: what makes a good result, not just a valid request. List the optional fields a complete item has (a description, a level, a length\u2026) and what a good value looks like; draft them from the conversation, ask for facts only the user knows, and show the full item for confirmation before saving.",
|
|
229
|
+
"",
|
|
182
230
|
"Steps:",
|
|
183
231
|
...tools.map((tool, index) => `${index + 1}. ${tool.id}${tool.label ? ` \u2014 ${tool.label}` : ""}`),
|
|
184
232
|
"Call only the tool that matches the user's actual request; respect the signed-in user's own permissions and confirm before any consequential write.",
|
|
@@ -284,7 +332,19 @@ var featuresConfigSchema = import_zod2.z.object({
|
|
|
284
332
|
* Internal dashboard mode: help administer existing workspaces, tools and
|
|
285
333
|
* conversations without restarting the onboarding procedure.
|
|
286
334
|
*/
|
|
287
|
-
configurationAssistant: import_zod2.z.boolean().default(false)
|
|
335
|
+
configurationAssistant: import_zod2.z.boolean().default(false),
|
|
336
|
+
/**
|
|
337
|
+
* Human escalation: give the agent a built-in tool to hand a conversation to
|
|
338
|
+
* the workspace's team when it can't resolve something or the visitor asks
|
|
339
|
+
* for a person. Each escalation is recorded on the conversation (a dashboard
|
|
340
|
+
* inbox) and, when `webhookUrl` is set, POSTed there (Slack, Zapier, a
|
|
341
|
+
* ticketing intake…). For support/landing-page agents.
|
|
342
|
+
*/
|
|
343
|
+
escalation: import_zod2.z.object({
|
|
344
|
+
enabled: import_zod2.z.boolean().default(false),
|
|
345
|
+
/** Optional HTTPS endpoint that receives each escalation as JSON. */
|
|
346
|
+
webhookUrl: import_zod2.z.string().url().startsWith("https://").max(2e3).optional()
|
|
347
|
+
}).prefault({})
|
|
288
348
|
}).prefault({});
|
|
289
349
|
var mcpServerSchema = import_zod2.z.object({
|
|
290
350
|
name: import_zod2.z.string().min(1).regex(/^[a-z0-9][a-z0-9_-]*$/i, "MCP server names must be alphanumeric with - or _"),
|
|
@@ -298,7 +358,8 @@ var mcpServerSchema = import_zod2.z.object({
|
|
|
298
358
|
enabled: import_zod2.z.boolean().default(true),
|
|
299
359
|
/** Explicit tool selection. Omitted keeps legacy all-tools behavior; [] disables all. */
|
|
300
360
|
selectedTools: import_zod2.z.array(import_zod2.z.string().min(1)).optional(),
|
|
301
|
-
|
|
361
|
+
/** Tools the server listed at discovery; `inputs` summarizes each one's input schema for skill writing. */
|
|
362
|
+
discoveredTools: import_zod2.z.array(import_zod2.z.object({ name: import_zod2.z.string(), description: import_zod2.z.string().optional(), inputs: import_zod2.z.string().optional() })).default([]),
|
|
302
363
|
/** `http` covers streamable HTTP; `sse` is the legacy transport. */
|
|
303
364
|
transport: import_zod2.z.enum(["http", "sse"]).default("http"),
|
|
304
365
|
url: import_zod2.z.string().url(),
|
|
@@ -318,6 +379,14 @@ var apiCapabilitySchema = import_zod2.z.object({
|
|
|
318
379
|
method: import_zod2.z.enum(["GET", "POST", "PUT", "PATCH", "DELETE"]),
|
|
319
380
|
path: import_zod2.z.string().min(1),
|
|
320
381
|
summary: import_zod2.z.string().optional(),
|
|
382
|
+
/**
|
|
383
|
+
* One line naming what the operation takes — its path/query parameters and
|
|
384
|
+
* the request body's top-level fields, each marked required where the spec
|
|
385
|
+
* says so, with its description or allowed values. This is what lets a
|
|
386
|
+
* skill say which inputs to collect before calling the tool (and which it
|
|
387
|
+
* must never invent), instead of "check the tool's own parameters".
|
|
388
|
+
*/
|
|
389
|
+
inputs: import_zod2.z.string().optional(),
|
|
321
390
|
/**
|
|
322
391
|
* True when this is a write operation the spec requires auth for, but the
|
|
323
392
|
* source has no way to authorize it as the specific calling user — no
|
|
@@ -425,10 +494,26 @@ var toolPolicySchema = import_zod2.z.object({
|
|
|
425
494
|
*/
|
|
426
495
|
denyByDefault: import_zod2.z.boolean().default(true)
|
|
427
496
|
});
|
|
497
|
+
var STARTER_PROMPT_ICONS = [
|
|
498
|
+
"sparkles",
|
|
499
|
+
"wand",
|
|
500
|
+
"book",
|
|
501
|
+
"chat",
|
|
502
|
+
"chart",
|
|
503
|
+
"bolt",
|
|
504
|
+
"music",
|
|
505
|
+
"mic",
|
|
506
|
+
"image",
|
|
507
|
+
"compass",
|
|
508
|
+
"lightbulb",
|
|
509
|
+
"file"
|
|
510
|
+
];
|
|
428
511
|
var starterPromptSchema = import_zod2.z.object({
|
|
429
512
|
title: import_zod2.z.string().trim().min(1).max(80),
|
|
430
513
|
description: import_zod2.z.string().trim().max(160).optional(),
|
|
431
|
-
message: import_zod2.z.string().trim().min(1).max(2e3)
|
|
514
|
+
message: import_zod2.z.string().trim().min(1).max(2e3),
|
|
515
|
+
/** One of STARTER_PROMPT_ICONS; unknown values fall back to a default icon. */
|
|
516
|
+
icon: import_zod2.z.enum(STARTER_PROMPT_ICONS).optional()
|
|
432
517
|
});
|
|
433
518
|
var agentConfigSchema = import_zod2.z.object({
|
|
434
519
|
/** Prepended to the runtime's own operating instructions. */
|
|
@@ -502,6 +587,46 @@ var brandingConfigSchema = import_zod2.z.object({
|
|
|
502
587
|
avatarUrl: import_zod2.z.string().url().optional(),
|
|
503
588
|
launcherLabel: import_zod2.z.string().default("Need a hand?")
|
|
504
589
|
});
|
|
590
|
+
var knowledgeSourceSchema = import_zod2.z.object({
|
|
591
|
+
url: import_zod2.z.string().url().startsWith("https://").max(2e3)
|
|
592
|
+
});
|
|
593
|
+
var knowledgeBaseSchema = import_zod2.z.object({
|
|
594
|
+
enabled: import_zod2.z.boolean().default(false),
|
|
595
|
+
sources: import_zod2.z.array(knowledgeSourceSchema).max(20).default([]),
|
|
596
|
+
/** Cap on pages fetched per source during a crawl. */
|
|
597
|
+
maxPages: import_zod2.z.number().int().positive().max(200).default(40)
|
|
598
|
+
}).prefault({});
|
|
599
|
+
var personaProfileSchema = import_zod2.z.object({
|
|
600
|
+
/** Stable lowercase slug, unique within the workspace. Baked into env var, branch and mount module. */
|
|
601
|
+
id: import_zod2.z.string().min(1).max(40).regex(/^[a-z0-9][a-z0-9-]*$/, "Persona ids are lowercase slugs."),
|
|
602
|
+
/** Tab label and install-PR title suffix, e.g. "Knowledge base". */
|
|
603
|
+
label: import_zod2.z.string().trim().min(1).max(60),
|
|
604
|
+
/** This persona's own publishable key. One key maps to exactly one persona. */
|
|
605
|
+
publishableKey: import_zod2.z.string().min(8),
|
|
606
|
+
/** App-Router path globs this persona shows on. Empty = every page no other agent claims. */
|
|
607
|
+
pagePatterns: import_zod2.z.array(import_zod2.z.string().trim().min(1).max(200)).max(20).default([]),
|
|
608
|
+
/**
|
|
609
|
+
* Whether visitors may chat with this persona without signing in to the host
|
|
610
|
+
* app. Unset inherits the workspace's `auth.allowAnonymous`. A support or docs
|
|
611
|
+
* agent on public pages needs this on, or every logged-out visitor is told to
|
|
612
|
+
* sign in.
|
|
613
|
+
*/
|
|
614
|
+
allowAnonymous: import_zod2.z.boolean().optional(),
|
|
615
|
+
/**
|
|
616
|
+
* Shallow-merged over the workspace branding. Each field is optional with no
|
|
617
|
+
* default, so only the keys the persona sets override the workspace's — an
|
|
618
|
+
* absent field keeps the workspace value rather than resetting it.
|
|
619
|
+
*/
|
|
620
|
+
branding: import_zod2.z.object({
|
|
621
|
+
name: import_zod2.z.string().optional(),
|
|
622
|
+
tagline: import_zod2.z.string().optional(),
|
|
623
|
+
avatarUrl: import_zod2.z.string().url().optional(),
|
|
624
|
+
launcherLabel: import_zod2.z.string().optional()
|
|
625
|
+
}).optional(),
|
|
626
|
+
agent: agentConfigSchema.prefault({}),
|
|
627
|
+
features: featuresConfigSchema,
|
|
628
|
+
knowledgeBase: knowledgeBaseSchema
|
|
629
|
+
});
|
|
505
630
|
var frankieConfigSchema = import_zod2.z.object({
|
|
506
631
|
tenantId: import_zod2.z.string().min(1),
|
|
507
632
|
branding: brandingConfigSchema.prefault({}),
|
|
@@ -516,7 +641,18 @@ var frankieConfigSchema = import_zod2.z.object({
|
|
|
516
641
|
skills: import_zod2.z.array(skillSchema).max(30).default([]),
|
|
517
642
|
limits: limitsConfigSchema.prefault({}),
|
|
518
643
|
/** Opt-in end-user chat capabilities (attachments, model picker). */
|
|
519
|
-
features: featuresConfigSchema
|
|
644
|
+
features: featuresConfigSchema,
|
|
645
|
+
/** Documentation the agent answers from, indexed for semantic retrieval. */
|
|
646
|
+
knowledgeBase: knowledgeBaseSchema,
|
|
647
|
+
/** App-Router path globs the primary (product) agent shows on. Empty = every page no persona claims. */
|
|
648
|
+
pagePatterns: import_zod2.z.array(import_zod2.z.string().trim().min(1).max(200)).max(20).default([]),
|
|
649
|
+
/**
|
|
650
|
+
* Additional embedded personas on this workspace. Empty for a single-agent
|
|
651
|
+
* workspace — the top-level agent/features/knowledgeBase + `auth.publishableKey`
|
|
652
|
+
* is the primary persona. Each entry resolves to its own one-agent config via
|
|
653
|
+
* its own publishable key, so the data plane still sees one config per key.
|
|
654
|
+
*/
|
|
655
|
+
personas: import_zod2.z.array(personaProfileSchema).max(8).default([])
|
|
520
656
|
});
|
|
521
657
|
var LEAKY_MODEL_MIGRATION = {
|
|
522
658
|
"accounts/fireworks/models/kimi-k2p7-code": "accounts/fireworks/models/kimi-k3"
|
|
@@ -532,8 +668,64 @@ function parseFrankieConfig(input) {
|
|
|
532
668
|
function defineFrankieConfig(config) {
|
|
533
669
|
return config;
|
|
534
670
|
}
|
|
671
|
+
function resolvePersonaConfig(config, publishableKey) {
|
|
672
|
+
if (publishableKey === config.auth.publishableKey) return config;
|
|
673
|
+
const persona = config.personas.find((entry) => entry.publishableKey === publishableKey);
|
|
674
|
+
if (!persona) return null;
|
|
675
|
+
return parseFrankieConfig({
|
|
676
|
+
...config,
|
|
677
|
+
personas: [],
|
|
678
|
+
branding: { ...config.branding, ...persona.branding ?? {} },
|
|
679
|
+
auth: { ...config.auth, publishableKey: persona.publishableKey, allowAnonymous: persona.allowAnonymous ?? config.auth.allowAnonymous },
|
|
680
|
+
pagePatterns: persona.pagePatterns,
|
|
681
|
+
agent: persona.agent,
|
|
682
|
+
features: persona.features,
|
|
683
|
+
knowledgeBase: persona.knowledgeBase
|
|
684
|
+
});
|
|
685
|
+
}
|
|
686
|
+
function normalizeRoutePath(value) {
|
|
687
|
+
let path = (value || "/").split(/[?#]/)[0].trim();
|
|
688
|
+
if (!path.startsWith("/")) path = "/" + path;
|
|
689
|
+
if (path.length > 1 && path.endsWith("/")) path = path.slice(0, -1);
|
|
690
|
+
return path;
|
|
691
|
+
}
|
|
692
|
+
function matchesPagePattern(pathname, pattern) {
|
|
693
|
+
const path = normalizeRoutePath(pathname);
|
|
694
|
+
const pat = normalizeRoutePath(pattern);
|
|
695
|
+
const trailingDeep = pat.endsWith("/**");
|
|
696
|
+
const core = trailingDeep ? pat.slice(0, -3) : pat;
|
|
697
|
+
const escaped = core.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
698
|
+
const body = escaped.split("**").map((chunk) => chunk.replace(/\*/g, "[^/]*")).join(".*");
|
|
699
|
+
return new RegExp("^" + body + (trailingDeep ? "(?:/.*)?" : "") + "$").test(path);
|
|
700
|
+
}
|
|
701
|
+
function matchesAnyPagePattern(pathname, patterns) {
|
|
702
|
+
return patterns.length === 0 || patterns.some((pattern) => matchesPagePattern(pathname, pattern));
|
|
703
|
+
}
|
|
535
704
|
|
|
536
705
|
// src/install.ts
|
|
706
|
+
function personaEnvKey(personaId) {
|
|
707
|
+
return "NEXT_PUBLIC_FRANKIE_KEY_" + personaId.toUpperCase().replace(/[^A-Z0-9]/g, "_");
|
|
708
|
+
}
|
|
709
|
+
var STANDARD_AGENT_IDS = ["support", "docs"];
|
|
710
|
+
function installAgentIds(personaIds = []) {
|
|
711
|
+
const custom = [...new Set(personaIds)].filter((id) => /^[a-z0-9][a-z0-9-]*$/.test(id) && !STANDARD_AGENT_IDS.includes(id)).sort();
|
|
712
|
+
return [...STANDARD_AGENT_IDS, ...custom];
|
|
713
|
+
}
|
|
714
|
+
function installBranch(workspaceId, baseSha, personaId) {
|
|
715
|
+
return "frankie/install-" + workspaceId + (personaId ? "--" + personaId : "") + "-" + baseSha.slice(0, 8);
|
|
716
|
+
}
|
|
717
|
+
function plainText(value, max = 80) {
|
|
718
|
+
const text = value.replace(/[\u0000-\u001f\u007f]+/g, " ").replace(/[`<>[\]()*_#|!\\]/g, "").replace(/\s+/g, " ").trim();
|
|
719
|
+
return text.length > max ? text.slice(0, max - 1).trimEnd() + "\u2026" : text;
|
|
720
|
+
}
|
|
721
|
+
function installTitle(personaLabel) {
|
|
722
|
+
return personaLabel ? "Install Frankie: " + plainText(personaLabel, 60) : "Install Frankie";
|
|
723
|
+
}
|
|
724
|
+
function matchesInstallBranch(ref, workspaceId, personaId) {
|
|
725
|
+
const escape = (value) => value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
726
|
+
const mid = personaId ? escape(workspaceId) + "--?" + escape(personaId) : escape(workspaceId);
|
|
727
|
+
return new RegExp("^frankie/install-" + mid + "-[0-9a-f]{8}$").test(ref);
|
|
728
|
+
}
|
|
537
729
|
function assertNoCommittedFrankieKeys(files) {
|
|
538
730
|
for (const file of files) {
|
|
539
731
|
if (/\bpk_live_[0-9a-f]{24}\b/i.test(file.content) || /-----BEGIN (?:RSA |EC |OPENSSH )?PRIVATE KEY-----/.test(file.content)) {
|
|
@@ -541,8 +733,17 @@ function assertNoCommittedFrankieKeys(files) {
|
|
|
541
733
|
}
|
|
542
734
|
}
|
|
543
735
|
}
|
|
736
|
+
var ENV_BLOCK_START = "# --- Frankie (managed) ---";
|
|
737
|
+
var ENV_BLOCK_END = "# --- end Frankie ---";
|
|
738
|
+
var LEGACY_ENV_COMMENTS = [
|
|
739
|
+
"# Public at runtime, but inject it through your deployment environment instead of committing a workspace value.",
|
|
740
|
+
"# Server only: use the secret from this workspace. Never commit its value.",
|
|
741
|
+
"# This persona's publishable key. Public at runtime; set it in your deployment environment, don't commit a value."
|
|
742
|
+
];
|
|
544
743
|
function planHostedInstall(input) {
|
|
545
744
|
const { files, workspace } = input;
|
|
745
|
+
const product = !input.persona;
|
|
746
|
+
const anonymous = workspace.allowAnonymous;
|
|
546
747
|
const layouts = Object.keys(files).filter((p) => /(?:^|\/)(?:src\/)?app(?:\/\([^/]+\))?\/layout\.tsx$/.test(p));
|
|
547
748
|
const roots = new Set(layouts.map((path) => path.replace(/app\/(?:\([^/]+\)\/)?layout\.tsx$/, "app/")));
|
|
548
749
|
if (roots.size > 1) throw new Error("Multiple Next.js applications found. Run the installer from the intended app with the CLI; Frankie will not guess which app to modify.");
|
|
@@ -556,34 +757,70 @@ function planHostedInstall(input) {
|
|
|
556
757
|
const pkg = JSON.parse(files[pkgPath] ?? "{}");
|
|
557
758
|
if (!pkg.dependencies?.next) throw new Error("The selected app must have Next.js in dependencies.");
|
|
558
759
|
const out = /* @__PURE__ */ new Map();
|
|
760
|
+
const deletions = [];
|
|
559
761
|
const notes = [];
|
|
560
|
-
const existingMount = files[layoutPath].includes("FrankieWidgetMount");
|
|
561
762
|
const sourceRoot = appRoot.endsWith("src/app/") ? root + "src/" : root;
|
|
562
763
|
const tokenPath = appRoot + "api/frankie/token/route.ts";
|
|
563
764
|
const healthPath = appRoot + "api/frankie/health/route.ts";
|
|
564
|
-
const
|
|
565
|
-
const
|
|
765
|
+
const agentsPath = appRoot + "frankie/FrankieAgents.tsx";
|
|
766
|
+
const chatModulePath = appRoot + "frankie/FrankieChatPage.tsx";
|
|
767
|
+
const importer = moduleImporter(files, root, sourceRoot);
|
|
768
|
+
const auth = detectAuth(files, sourceRoot, pkg.dependencies, (module2) => importer(tokenPath, module2));
|
|
769
|
+
const existingToken = files[tokenPath];
|
|
770
|
+
const authStrategy = anonymous ? "anonymous" : auth?.strategy ?? (existingToken && !existingToken.includes("AUTH_NOT_CONFIGURED") ? "existing" : "unconfigured");
|
|
566
771
|
const needsAuthSetup = authStrategy === "unconfigured";
|
|
567
|
-
if (needsAuthSetup) notes.push("No session lookup was detected. Export getCurrentUser from lib/frankie-auth.ts (see the generated token endpoint); until then it answers 501 and chat reports that sign-in is not wired.");
|
|
568
|
-
if (authStrategy === "supabase") notes.push("Detected Supabase auth: the token endpoint verifies the user's Supabase access token and the widget forwards it automatically. Users must be signed in through Supabase.");
|
|
569
|
-
pkg.dependencies =
|
|
570
|
-
out.set(pkgPath,
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
772
|
+
if (product && needsAuthSetup) notes.push("No session lookup was detected. Export getCurrentUser from lib/frankie-auth.ts (see the generated token endpoint); until then it answers 501 and chat reports that sign-in is not wired.");
|
|
773
|
+
if (product && authStrategy === "supabase") notes.push("Detected Supabase auth: the token endpoint verifies the user's Supabase access token and the widget forwards it automatically. Users must be signed in through Supabase.");
|
|
774
|
+
pkg.dependencies = withDependencies(pkg.dependencies, { "@frankieai/react": "^0.2.1", ...!anonymous ? { jose: "^6.0.11" } : {} });
|
|
775
|
+
out.set(pkgPath, renderJson(pkg, files[pkgPath]));
|
|
776
|
+
let manifest = {};
|
|
777
|
+
try {
|
|
778
|
+
const parsed = JSON.parse(files[root + "frankie.install.json"] ?? "{}");
|
|
779
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) manifest = parsed;
|
|
780
|
+
} catch {
|
|
781
|
+
}
|
|
782
|
+
const priorChat = manifest.workspaceId === workspace.id && (manifest.chatPath === "/chat" || manifest.chatPath === "/frankie/chat") ? manifest.chatPath : void 0;
|
|
783
|
+
const chatPath = priorChat ?? (files[integrationAppRoot + "chat/page.tsx"] ? "/frankie/chat" : "/chat");
|
|
784
|
+
let legacyChat = false;
|
|
785
|
+
for (const path of Object.keys(files).sort()) {
|
|
786
|
+
if (!path.startsWith(appRoot)) continue;
|
|
787
|
+
const legacy = path.match(/(?:^|\/)frankie\/(?:FrankieWidgetMount|FrankieSetupChat)\.tsx$/);
|
|
788
|
+
if (legacy) {
|
|
789
|
+
const exportsChat = /\bWorkspaceChatPage\b/.test(files[path]);
|
|
790
|
+
legacyChat ||= exportsChat;
|
|
791
|
+
out.set(path, `// Frankie now mounts every agent from FrankieAgents in the root layout.
|
|
792
|
+
// This module stays so existing imports keep working; it renders nothing.
|
|
793
|
+
export function FrankieWidgetMount() {
|
|
794
|
+
return null;
|
|
795
|
+
}
|
|
796
|
+
${exportsChat ? `export { WorkspaceChatPage } from "${relativeImport(path, chatModulePath.slice(0, -4))}";
|
|
797
|
+
` : ""}`);
|
|
798
|
+
}
|
|
799
|
+
if (/(?:^|\/)frankie\/FrankieWidget\.[a-z0-9][a-z0-9-]*\.tsx$/.test(path)) deletions.push(path);
|
|
800
|
+
}
|
|
801
|
+
const clientAuth = anonymous ? void 0 : auth?.client;
|
|
802
|
+
const clientAuthImport = clientAuth ? `import { ${clientAuth.name} } from "${importer(agentsPath, "lib/supabase")}";
|
|
576
803
|
` : "";
|
|
577
|
-
const
|
|
578
|
-
async function
|
|
579
|
-
const session = (await ${
|
|
580
|
-
|
|
581
|
-
|
|
804
|
+
const tokenHelpers = anonymous ? "" : clientAuth ? `
|
|
805
|
+
async function supabaseToken() {
|
|
806
|
+
const session = (await ${clientAuth.name}?.auth.getSession())?.data.session;
|
|
807
|
+
return session?.access_token;
|
|
808
|
+
}
|
|
809
|
+
/** The signed-in user's Frankie token, or undefined when nobody is signed in. */
|
|
810
|
+
export async function getUserToken() {
|
|
811
|
+
const accessToken = await supabaseToken();
|
|
812
|
+
if (!accessToken) return undefined;
|
|
813
|
+
const response = await fetch("/api/frankie/token", { method: "POST", headers: { Authorization: \`Bearer \${accessToken}\` } });
|
|
582
814
|
if (!response.ok) return undefined;
|
|
583
815
|
return ((await response.json()) as { token?: string }).token;
|
|
584
816
|
}
|
|
817
|
+
const optionalUserToken = getUserToken;
|
|
818
|
+
async function productAvailable() {
|
|
819
|
+
return Boolean(await supabaseToken());
|
|
820
|
+
}
|
|
585
821
|
` : `
|
|
586
|
-
|
|
822
|
+
/** The signed-in user's Frankie token; throws the reason chat can't start. */
|
|
823
|
+
export async function getUserToken() {
|
|
587
824
|
const response = await fetch("/api/frankie/token", { method: "POST", credentials: "same-origin" });
|
|
588
825
|
if (response.ok) return ((await response.json()) as { token?: string }).token;
|
|
589
826
|
if (response.status === 401) throw new Error("Sign in to this app to use chat.");
|
|
@@ -591,65 +828,153 @@ async function getUserToken() {
|
|
|
591
828
|
if (response.status === 503) throw new Error("Chat is not configured on the server yet (FRANKIE_JWT_SECRET is missing).");
|
|
592
829
|
throw new Error("The chat sign-in endpoint returned " + response.status + ".");
|
|
593
830
|
}
|
|
831
|
+
/** Support and docs agents chat with visitors too: who is signed in, if anyone. */
|
|
832
|
+
async function optionalUserToken() {
|
|
833
|
+
try {
|
|
834
|
+
const response = await fetch("/api/frankie/token", { method: "POST", credentials: "same-origin" });
|
|
835
|
+
return response.ok ? ((await response.json()) as { token?: string }).token : undefined;
|
|
836
|
+
} catch {
|
|
837
|
+
return undefined;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
/** The product agent is for signed-in users. 501/503 still show it, so the widget can say what to fix. */
|
|
841
|
+
async function productAvailable() {
|
|
842
|
+
try {
|
|
843
|
+
const response = await fetch("/api/frankie/token", { method: "POST", credentials: "same-origin" });
|
|
844
|
+
return response.ok || response.status === 501 || response.status === 503;
|
|
845
|
+
} catch {
|
|
846
|
+
return false;
|
|
847
|
+
}
|
|
848
|
+
}
|
|
594
849
|
`;
|
|
595
|
-
const hostApiHeaders =
|
|
596
|
-
headers: async () => {
|
|
597
|
-
const session = (await ${
|
|
850
|
+
const hostApiHeaders = clientAuth ? `
|
|
851
|
+
headers: async (): Promise<Record<string, string>> => {
|
|
852
|
+
const session = (await ${clientAuth.name}?.auth.getSession())?.data.session;
|
|
598
853
|
return session?.access_token ? { Authorization: \`Bearer \${session.access_token}\` } : {};
|
|
599
854
|
},` : "";
|
|
600
|
-
const hostApi = `const hostApi = {
|
|
855
|
+
const hostApi = `export const hostApi = {
|
|
601
856
|
baseUrl: typeof window !== "undefined" ? window.location.origin : "",
|
|
602
|
-
credentials: "${
|
|
857
|
+
credentials: "${clientAuth || anonymous ? "same-origin" : "include"}" as const,${hostApiHeaders}
|
|
603
858
|
};
|
|
604
859
|
`;
|
|
605
|
-
const
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
}
|
|
612
|
-
const chatPath = priorChat ?? (files[integrationAppRoot + "chat/page.tsx"] ? "/frankie/chat" : "/chat");
|
|
613
|
-
if (!priorChat && files[integrationAppRoot + chatPath.slice(1) + "/page.tsx"]) throw new Error("Both /chat and /frankie/chat already exist. Choose a chat route manually.");
|
|
614
|
-
out.set(root + "frankie.install.json", JSON.stringify({ workspaceId: workspace.id, chatPath }, null, 2) + "\n");
|
|
615
|
-
if (chatPath !== "/chat") notes.push("Existing /chat preserved. Frankie chat is available at /frankie/chat.");
|
|
616
|
-
const mount = `"use client";
|
|
860
|
+
const personaIds = installAgentIds([...input.agents ?? [], ...input.persona ? [input.persona.id] : []]);
|
|
861
|
+
const agentsModule = `"use client";
|
|
862
|
+
// Generated by Frankie. Every Frankie agent in this app mounts here, once, from
|
|
863
|
+
// the root layout. An agent turns on when its publishable key is set in the
|
|
864
|
+
// deployment environment, and shows on the pages chosen for it in Frankie.
|
|
865
|
+
import { useEffect, useState } from "react";
|
|
617
866
|
import { usePathname } from "next/navigation";
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
867
|
+
import { FrankieClient, FrankieProvider, FrankieWidget, type PublicWidgetConfig } from "@frankieai/react";
|
|
868
|
+
import "@frankieai/react/styles.css";
|
|
869
|
+
${clientAuthImport}${tokenHelpers}${hostApi}
|
|
870
|
+
type Agent = { id: string; key: string | undefined };
|
|
871
|
+
// Literal process.env reads, so Next.js inlines each key at build time. Where
|
|
872
|
+
// two agents' pages overlap the earlier one wins; the product agent takes
|
|
873
|
+
// every page no other agent claims.
|
|
874
|
+
const PERSONAS: Agent[] = [
|
|
875
|
+
${personaIds.map((id) => ` { id: ${JSON.stringify(id)}, key: process.env.${personaEnvKey(id)} },`).join("\n")}
|
|
876
|
+
];
|
|
877
|
+
const PRODUCT: Agent = { id: "product", key: process.env.NEXT_PUBLIC_FRANKIE_KEY };
|
|
878
|
+
// The full-page chat never gets a floating launcher on top of it.
|
|
879
|
+
const CHAT_PATHS = ["/chat", "/frankie/chat"];
|
|
880
|
+
|
|
881
|
+
type Loaded = { config: PublicWidgetConfig | undefined; pages: string[] };
|
|
882
|
+
const loading = new Map<string, Promise<Loaded>>();
|
|
883
|
+
function load(key: string): Promise<Loaded> {
|
|
884
|
+
let pending = loading.get(key);
|
|
885
|
+
if (!pending) {
|
|
886
|
+
pending = new FrankieClient({ publishableKey: key })
|
|
887
|
+
.getConfig()
|
|
888
|
+
.then((config) => ({ config, pages: (config as { pages?: string[] }).pages ?? [] }))
|
|
889
|
+
.catch(() => ({ config: undefined, pages: [] }));
|
|
890
|
+
loading.set(key, pending);
|
|
891
|
+
}
|
|
892
|
+
return pending;
|
|
624
893
|
}
|
|
894
|
+
|
|
895
|
+
function matchesAny(pathname: string, patterns: string[]): boolean {
|
|
896
|
+
if (!patterns.length) return true;
|
|
897
|
+
const path = (pathname || "/").split(/[?#]/)[0]!.replace(/(.)\\/+$/, "$1");
|
|
898
|
+
return patterns.some((pattern) => {
|
|
899
|
+
const pat = (pattern || "/").replace(/(.)\\/+$/, "$1");
|
|
900
|
+
const deep = pat.endsWith("/**");
|
|
901
|
+
const core = (deep ? pat.slice(0, -3) : pat).replace(/[.+^\${}()|[\\]\\\\]/g, "\\\\$&");
|
|
902
|
+
const body = core.split("**").map((chunk) => chunk.replace(/\\*/g, "[^/]*")).join(".*");
|
|
903
|
+
return new RegExp("^" + body + (deep ? "(?:/.*)?" : "") + "$").test(path);
|
|
904
|
+
});
|
|
905
|
+
}
|
|
906
|
+
|
|
907
|
+
function pick(pathname: string, loaded: Record<string, Loaded>, signedIn: boolean): { agent: Agent; key: string } | undefined {
|
|
908
|
+
if (CHAT_PATHS.includes(pathname)) return undefined;
|
|
909
|
+
const live = [...PERSONAS, PRODUCT].flatMap((agent) => (agent.key ? [{ agent, key: agent.key }] : []));
|
|
910
|
+
// Decide once every agent's pages are known, so one never flashes in before another.
|
|
911
|
+
if (live.some(({ key }) => !loaded[key])) return undefined;
|
|
912
|
+
const personas = live.filter(({ agent, key }) => agent !== PRODUCT && loaded[key]!.config);
|
|
913
|
+
const claimed = personas.find(({ key }) => loaded[key]!.pages.length > 0 && matchesAny(pathname, loaded[key]!.pages));
|
|
914
|
+
if (claimed) return claimed;
|
|
915
|
+
const product = live.find(({ agent }) => agent === PRODUCT);
|
|
916
|
+
if (product && signedIn && matchesAny(pathname, loaded[product.key]!.pages)) return product;
|
|
917
|
+
return personas.find(({ key }) => loaded[key]!.pages.length === 0);
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
export function FrankieAgents() {
|
|
921
|
+
const pathname = usePathname() || "/";
|
|
922
|
+
const [loaded, setLoaded] = useState<Record<string, Loaded>>({});
|
|
923
|
+
useEffect(() => {
|
|
924
|
+
let live = true;
|
|
925
|
+
for (const { key } of [...PERSONAS, PRODUCT]) {
|
|
926
|
+
if (key) void load(key).then((result) => { if (live) setLoaded((prev) => ({ ...prev, [key]: result })); });
|
|
927
|
+
}
|
|
928
|
+
return () => { live = false; };
|
|
929
|
+
}, []);
|
|
930
|
+
${anonymous ? ` const signedIn = true;
|
|
931
|
+
` : ` const [signedIn, setSignedIn] = useState(false);
|
|
932
|
+
useEffect(() => {
|
|
933
|
+
if (!PRODUCT.key) return;
|
|
934
|
+
let live = true;
|
|
935
|
+
void productAvailable().then((ok) => { if (live) setSignedIn(ok); });
|
|
936
|
+
return () => { live = false; };
|
|
937
|
+
}, [pathname]);
|
|
938
|
+
`} const active = pick(pathname, loaded, signedIn);
|
|
939
|
+
if (!active) return null;
|
|
940
|
+
return (
|
|
941
|
+
<FrankieProvider key={active.key} publishableKey={active.key} config={loaded[active.key]?.config}${anonymous ? "" : ` getUserToken={active.agent === PRODUCT ? getUserToken : optionalUserToken}`} api={hostApi}>
|
|
942
|
+
<FrankieWidget />
|
|
943
|
+
</FrankieProvider>
|
|
944
|
+
);
|
|
945
|
+
}
|
|
946
|
+
`;
|
|
947
|
+
out.set(agentsPath, agentsModule);
|
|
948
|
+
const rootLayout = files[rootLayoutPath];
|
|
949
|
+
if (!rootLayout.includes("FrankieAgents")) {
|
|
950
|
+
const mounted = mountWidget(rootLayout, "FrankieAgents");
|
|
951
|
+
if (!mounted) throw new Error("The root layout has no safe mount point. Add <FrankieAgents /> inside <body> manually.");
|
|
952
|
+
out.set(rootLayoutPath, insertImport(mounted, `import { FrankieAgents } from "./frankie/FrankieAgents";`));
|
|
953
|
+
}
|
|
954
|
+
if (product || legacyChat) {
|
|
955
|
+
out.set(chatModulePath, `"use client";
|
|
956
|
+
import { FrankieChatPage } from "@frankieai/react";
|
|
957
|
+
import "@frankieai/react/styles.css";
|
|
958
|
+
import { ${anonymous ? "" : "getUserToken, "}hostApi } from "./FrankieAgents";
|
|
959
|
+
|
|
625
960
|
export function WorkspaceChatPage() {
|
|
626
961
|
const key = process.env.NEXT_PUBLIC_FRANKIE_KEY;
|
|
627
962
|
if (!key) return <p>Chat is not configured. Set NEXT_PUBLIC_FRANKIE_KEY in your deployment.</p>;
|
|
628
|
-
return <FrankieChatPage publishableKey={key}${
|
|
963
|
+
return <FrankieChatPage publishableKey={key}${anonymous ? "" : " getUserToken={getUserToken}"} api={hostApi} />;
|
|
629
964
|
}
|
|
630
|
-
|
|
631
|
-
const moduleName = existingMount ? "FrankieSetupChat" : "FrankieWidgetMount";
|
|
632
|
-
out.set(integrationAppRoot + "frankie/" + moduleName + ".tsx", mount);
|
|
633
|
-
if (!existingMount) {
|
|
634
|
-
const layout = files[layoutPath];
|
|
635
|
-
const importLine = `import { FrankieWidgetMount } from "./frankie/FrankieWidgetMount";
|
|
636
|
-
`;
|
|
637
|
-
const withImport = /^["']use client["'];/.test(layout) ? layout.replace(/^(['"]use client['"];)/, "$1\n" + importLine) : importLine + layout;
|
|
638
|
-
if (withImport.includes("</body>")) {
|
|
639
|
-
out.set(layoutPath, withImport.replace("</body>", " <FrankieWidgetMount />\n</body>"));
|
|
640
|
-
} else if (withImport.includes("</main>")) {
|
|
641
|
-
out.set(layoutPath, withImport.replace("</main>", " <FrankieWidgetMount />\n</main>"));
|
|
642
|
-
} else if (withImport.includes("{children}")) {
|
|
643
|
-
out.set(layoutPath, withImport.replace("{children}", "{children}<FrankieWidgetMount />"));
|
|
644
|
-
} else {
|
|
645
|
-
throw new Error("The selected layout has no safe widget mount point. Add FrankieWidgetMount manually.");
|
|
646
|
-
}
|
|
965
|
+
`);
|
|
647
966
|
}
|
|
648
|
-
|
|
649
|
-
|
|
967
|
+
if (product) {
|
|
968
|
+
if (!priorChat && files[integrationAppRoot + chatPath.slice(1) + "/page.tsx"]) throw new Error("Both /chat and /frankie/chat already exist. Choose a chat route manually.");
|
|
969
|
+
const pagePath = integrationAppRoot + chatPath.slice(1) + "/page.tsx";
|
|
970
|
+
out.set(pagePath, `import { WorkspaceChatPage } from "${relativeImport(pagePath, chatModulePath.slice(0, -4))}";
|
|
650
971
|
export default WorkspaceChatPage;
|
|
651
972
|
`);
|
|
652
|
-
|
|
973
|
+
const { personas: _legacyPersonas, ...kept2 } = manifest;
|
|
974
|
+
out.set(root + "frankie.install.json", JSON.stringify({ ...kept2, workspaceId: workspace.id, chatPath }, null, 2) + "\n");
|
|
975
|
+
if (chatPath !== "/chat") notes.push("Existing /chat preserved. Frankie chat is available at /frankie/chat.");
|
|
976
|
+
}
|
|
977
|
+
if (product && !anonymous && !files[tokenPath]) {
|
|
653
978
|
const lookup = auth?.lookup ?? `// Frankie could not find your session lookup. Export it from lib/frankie-auth.ts:
|
|
654
979
|
// export async function getCurrentUser(): Promise<{ id: string; name?: string; email?: string } | null>
|
|
655
980
|
// then import it here in place of this stub.
|
|
@@ -677,8 +1002,7 @@ export async function POST(${auth?.handlerParam ?? ""}) {
|
|
|
677
1002
|
}
|
|
678
1003
|
`);
|
|
679
1004
|
}
|
|
680
|
-
|
|
681
|
-
out.set(healthPath, `import { NextResponse } from "next/server";
|
|
1005
|
+
out.set(healthPath, `import { NextResponse } from "next/server";
|
|
682
1006
|
|
|
683
1007
|
export const dynamic = "force-dynamic";
|
|
684
1008
|
|
|
@@ -687,88 +1011,128 @@ export async function GET() {
|
|
|
687
1011
|
{
|
|
688
1012
|
frankie: true,
|
|
689
1013
|
publishableKey: Boolean(process.env.NEXT_PUBLIC_FRANKIE_KEY),
|
|
1014
|
+
agents: {
|
|
1015
|
+
${personaIds.map((id) => ` ${JSON.stringify(id)}: Boolean(process.env.${personaEnvKey(id)}),`).join("\n")}
|
|
1016
|
+
},
|
|
690
1017
|
jwtSecret: Boolean(process.env.FRANKIE_JWT_SECRET),
|
|
691
1018
|
auth: ${JSON.stringify(authStrategy)},
|
|
692
|
-
chatPath: ${JSON.stringify(chatPath)},
|
|
693
1019
|
},
|
|
694
1020
|
{ headers: { "cache-control": "no-store", "access-control-allow-origin": "*" } },
|
|
695
1021
|
);
|
|
696
1022
|
}
|
|
697
1023
|
`);
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
1024
|
+
let operationCount = 0;
|
|
1025
|
+
const specPath = root + "public/frankie.openapi.json";
|
|
1026
|
+
if (product) {
|
|
1027
|
+
let doc;
|
|
1028
|
+
for (const [path, content] of Object.entries(files)) {
|
|
1029
|
+
if (!/(?:openapi|swagger)[^/]*\.json$/i.test(path)) continue;
|
|
1030
|
+
try {
|
|
1031
|
+
const parsed = JSON.parse(content);
|
|
1032
|
+
if (parsed.openapi && parsed.paths) {
|
|
1033
|
+
doc = parsed;
|
|
1034
|
+
break;
|
|
1035
|
+
}
|
|
1036
|
+
} catch {
|
|
707
1037
|
}
|
|
708
|
-
} catch {
|
|
709
1038
|
}
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
1039
|
+
const middlewareMatchers = nextMiddlewareMatchers(files, sourceRoot, root);
|
|
1040
|
+
const paths = {};
|
|
1041
|
+
let withheld = 0;
|
|
1042
|
+
for (const [file, content] of Object.entries(files)) {
|
|
1043
|
+
if (/\.(test|spec)\.[tj]sx?$/.test(file)) continue;
|
|
1044
|
+
const nextRoute = file.match(/(?:^|\/)app(\/.*)\/route\.[tj]s$/);
|
|
1045
|
+
if (nextRoute) {
|
|
1046
|
+
const path = nextRoute[1].replace(/\/\([^/]+\)/g, "").replace(/\[([^\]]+)\]/g, "{$1}");
|
|
1047
|
+
if (path.includes("...") || /\/(?:auth|frankie)(?:\/|$)/.test(path)) continue;
|
|
1048
|
+
const guarded = matchesAnyMatcher(middlewareMatchers, path) || IN_HANDLER_AUTH_GUARD.test(content);
|
|
1049
|
+
for (const method of ["GET", "POST", "PUT", "PATCH", "DELETE"]) {
|
|
1050
|
+
if (new RegExp("export\\s+(?:(?:async\\s+)?function\\s+" + method + "\\b|(?:const|let)\\s+" + method + "\\s*=)").test(content)) {
|
|
1051
|
+
if (!addInstallOperation(paths, method, path, file, guarded)) withheld += 1;
|
|
1052
|
+
}
|
|
724
1053
|
}
|
|
1054
|
+
} else if (/\.[tj]s$/.test(file) && /(?:routes?|api)\//.test(file)) {
|
|
1055
|
+
for (const match of content.matchAll(/\b(?:app|router|fastify)\.(get|post|put|patch|delete)(?:\s*<[^;]{1,1600}?>)?\s*\(\s*["']([^"']+)["'](\s*,\s*[A-Za-z_$][\w$]*\s*,)?/g))
|
|
1056
|
+
if (!addInstallOperation(paths, match[1].toUpperCase(), match[2].replace(/:([A-Za-z0-9_]+)/g, "{$1}"), file, Boolean(match[3]))) withheld += 1;
|
|
725
1057
|
}
|
|
726
|
-
} else if (/\.[tj]s$/.test(file) && /(?:routes?|api)\//.test(file)) {
|
|
727
|
-
for (const match of content.matchAll(/\b(?:app|router|fastify)\.(get|post|put|patch|delete)(?:\s*<[^;]{1,1600}?>)?\s*\(\s*["']([^"']+)["'](\s*,\s*[A-Za-z_$][\w$]*\s*,)?/g))
|
|
728
|
-
if (!addInstallOperation(paths, match[1].toUpperCase(), match[2].replace(/:([A-Za-z0-9_]+)/g, "{$1}"), file, Boolean(match[3]))) withheld += 1;
|
|
729
1058
|
}
|
|
1059
|
+
doc ??= { openapi: "3.0.3", info: { title: workspace.name + " API", version: "1.0.0", description: "Generated route inventory. Review schemas, authorization, and the deployed origin before enabling tools." }, paths };
|
|
1060
|
+
for (const [path, item] of Object.entries(paths)) doc.paths[path] = { ...item, ...doc.paths[path] };
|
|
1061
|
+
operationCount = Object.values(doc.paths).reduce((n, item) => n + Object.keys(item).filter((m) => /^(get|post|put|patch|delete)$/.test(m)).length, 0);
|
|
1062
|
+
out.set(specPath, JSON.stringify(doc, null, 2) + "\n");
|
|
1063
|
+
if (!operationCount) notes.push("No supported API routes detected. The OpenAPI draft has no operations; add the real schema before connecting tools.");
|
|
1064
|
+
if (withheld) notes.push(`Withheld ${withheld} newly discovered write operation(s) with no detected authorization guard (no Express/Fastify middleware argument, Next.js middleware.ts matcher covering the route, or in-handler auth check). Add them to the spec yourself once they're protected.`);
|
|
1065
|
+
notes.push("Generated OpenAPI includes read and write routes. Review schemas before approving tools; new connections still default to GET-only access.");
|
|
1066
|
+
notes.push("No public spec endpoint is required: Frankie connects this committed spec on install and reads later changes from your repository through the GitHub connection.");
|
|
1067
|
+
notes.push("The token endpoint issues a signed user JWT, but your product API must validate it and enforce user/resource permissions before exposing tools.");
|
|
1068
|
+
} else {
|
|
1069
|
+
const envKey = personaEnvKey(input.persona.id);
|
|
1070
|
+
notes.push(`${plainText(input.persona.label, 60)} turns on when ${envKey} is set in each frontend environment. Its pages come from Frankie at runtime, so changing them later needs no new pull request.`);
|
|
730
1071
|
}
|
|
731
|
-
doc ??= { openapi: "3.0.3", info: { title: workspace.name + " API", version: "1.0.0", description: "Generated route inventory. Review schemas, authorization, and the deployed origin before enabling tools." }, paths };
|
|
732
|
-
for (const [path, item] of Object.entries(paths)) doc.paths[path] = { ...item, ...doc.paths[path] };
|
|
733
|
-
const specPath = root + "public/frankie.openapi.json";
|
|
734
|
-
const operationCount = Object.values(doc.paths).reduce((n, item) => n + Object.keys(item).filter((m) => /^(get|post|put|patch|delete)$/.test(m)).length, 0);
|
|
735
|
-
out.set(specPath, JSON.stringify(doc, null, 2) + "\n");
|
|
736
|
-
if (!operationCount) notes.push("No supported API routes detected. The OpenAPI draft has no operations; add the real schema before connecting tools.");
|
|
737
|
-
if (withheld) notes.push(`Withheld ${withheld} newly discovered write operation(s) with no detected authorization guard (no Express/Fastify middleware argument, Next.js middleware.ts matcher covering the route, or in-handler auth check). Add them to the spec yourself once they're protected.`);
|
|
738
|
-
notes.push("Generated OpenAPI includes read and write routes. Review schemas before approving tools; new connections still default to GET-only access.");
|
|
739
|
-
notes.push("No public spec endpoint is required: Frankie connects this committed spec on install and reads later changes from your repository through the GitHub connection.");
|
|
740
|
-
notes.push("The token endpoint issues a signed user JWT, but your product API must validate it and enforce user/resource permissions before exposing tools.");
|
|
741
1072
|
const envPath = root + ".env.example";
|
|
742
|
-
const
|
|
743
|
-
|
|
744
|
-
|
|
1073
|
+
const keyLine = /^(?:NEXT_PUBLIC_FRANKIE_KEY(?:_[A-Z0-9_]+)?|FRANKIE_JWT_SECRET)=.*$/;
|
|
1074
|
+
const kept = [];
|
|
1075
|
+
let inBlock = false;
|
|
1076
|
+
for (const line of (files[envPath] ?? "").split("\n")) {
|
|
1077
|
+
if (line === ENV_BLOCK_START) {
|
|
1078
|
+
inBlock = true;
|
|
1079
|
+
continue;
|
|
1080
|
+
}
|
|
1081
|
+
if (line === ENV_BLOCK_END) {
|
|
1082
|
+
inBlock = false;
|
|
1083
|
+
continue;
|
|
1084
|
+
}
|
|
1085
|
+
if (inBlock || keyLine.test(line) || LEGACY_ENV_COMMENTS.includes(line)) continue;
|
|
1086
|
+
kept.push(line);
|
|
1087
|
+
}
|
|
1088
|
+
const before = kept.join("\n").trimEnd();
|
|
1089
|
+
const block = [
|
|
1090
|
+
ENV_BLOCK_START,
|
|
1091
|
+
"# Publishable keys are public at runtime, but set them in your deployment environment instead of committing values. An agent stays off until its key is set.",
|
|
1092
|
+
"NEXT_PUBLIC_FRANKIE_KEY=",
|
|
1093
|
+
...personaIds.map((id) => personaEnvKey(id) + "="),
|
|
1094
|
+
...anonymous ? [] : ["# Server only: the secret from your Frankie workspace. Never commit its value or expose it to the browser.", "FRANKIE_JWT_SECRET="],
|
|
1095
|
+
ENV_BLOCK_END
|
|
1096
|
+
].join("\n");
|
|
1097
|
+
out.set(envPath, (before ? before + "\n\n" : "") + block + "\n");
|
|
1098
|
+
if (product) {
|
|
1099
|
+
const keys = ["NEXT_PUBLIC_FRANKIE_KEY", ...personaIds.map(personaEnvKey)];
|
|
1100
|
+
out.set(root + "FRANKIE_SETUP.md", `# Frankie setup
|
|
745
1101
|
|
|
746
|
-
Workspace: ${workspace.name}
|
|
1102
|
+
Workspace: ${plainText(workspace.name)}
|
|
747
1103
|
|
|
748
|
-
1. Copy NEXT_PUBLIC_FRANKIE_KEY from Frankie \u2192 Workspace \u2192 Developer details into
|
|
749
|
-
${
|
|
750
|
-
4. Nothing to expose: this PR commits your spec to the repository, Frankie connects it for you on install, and later spec changes are read from your repository through the GitHub connection
|
|
1104
|
+
1. Copy NEXT_PUBLIC_FRANKIE_KEY from Frankie \u2192 Workspace \u2192 Developer details into every frontend deployment environment (Preview and Production). Frankie deliberately leaves the value blank in this PR.
|
|
1105
|
+
${anonymous ? "" : "2. Add FRANKIE_JWT_SECRET from this workspace to the server hosting /api/frankie/token only. Never commit the value or expose it to the browser.\n"}3. Run your package manager and commit the updated lockfile (for pnpm: pnpm install --lockfile-only). The remote GitHub installer cannot safely synthesize a lockfile.
|
|
1106
|
+
4. Nothing to expose: this PR commits your spec to the repository, Frankie connects it for you on install, and later spec changes are read from your repository through the GitHub connection.
|
|
751
1107
|
5. Open ${chatPath}. It is created inside the detected authenticated dashboard group when one exists; add its link to the host sidebar during review.
|
|
752
|
-
6. In Frankie \u2192 Tools & MCP, approve the operations you want and choose access limits
|
|
1108
|
+
6. In Frankie \u2192 Tools & MCP, approve the operations you want and choose access limits. After merging later spec changes, use GitHub integration \u2192 Refresh Frankie from merged spec.
|
|
753
1109
|
7. Forwarding sends the Frankie-signed JWT. Your API must validate it and enforce user/resource permissions. It is not automatically interchangeable with a Firebase or OAuth access token.
|
|
754
|
-
|
|
1110
|
+
|
|
1111
|
+
## More agents
|
|
1112
|
+
|
|
1113
|
+
Every Frankie agent mounts from \`frankie/FrankieAgents.tsx\` in the root layout. To turn on another agent (Support, Docs), set its key in the deployment \u2014 no code change:
|
|
1114
|
+
|
|
1115
|
+
${keys.map((key) => "- `" + key + "`").join("\n")}
|
|
1116
|
+
|
|
1117
|
+
Each agent shows on the pages chosen for it in Frankie; those are read at runtime.
|
|
755
1118
|
|
|
756
1119
|
## Remaining setup
|
|
757
1120
|
|
|
758
1121
|
${notes.map((n) => "- " + n).join("\n")}
|
|
759
1122
|
`);
|
|
760
|
-
|
|
1123
|
+
}
|
|
1124
|
+
const planned = [...out].filter(([path, content]) => files[path] !== content).map(([path, content]) => ({ path, content }));
|
|
761
1125
|
assertNoCommittedFrankieKeys(planned);
|
|
762
|
-
return { files: planned, root, chatPath, specPath, operationCount, needsAuthSetup, authStrategy, notes };
|
|
1126
|
+
return { files: planned, deletions, root, chatPath, specPath, operationCount, needsAuthSetup, authStrategy, notes };
|
|
763
1127
|
}
|
|
764
1128
|
var GET_CURRENT_USER = /export\s+(?:async\s+function\s+getCurrentUser\b|(?:const|let)\s+getCurrentUser\b|\{[^}]*\bgetCurrentUser\b[^}]*\})/;
|
|
765
1129
|
var CUSTOM_AUTH_MODULES = ["lib/frankie-auth", "frankie/auth", "lib/auth", "lib/session", "lib/current-user", "lib/user", "server/auth", "auth"];
|
|
766
|
-
function detectAuth(files, sourceRoot, deps) {
|
|
1130
|
+
function detectAuth(files, sourceRoot, deps, ref) {
|
|
767
1131
|
for (const module2 of CUSTOM_AUTH_MODULES) {
|
|
768
1132
|
for (const ext of [".ts", ".tsx", ".js"]) {
|
|
769
1133
|
const source = files[sourceRoot + module2 + ext];
|
|
770
1134
|
if (source && GET_CURRENT_USER.test(source)) {
|
|
771
|
-
return { strategy: "custom", lookup: `import { getCurrentUser } from "
|
|
1135
|
+
return { strategy: "custom", lookup: `import { getCurrentUser } from "${ref(module2)}";
|
|
772
1136
|
` };
|
|
773
1137
|
}
|
|
774
1138
|
}
|
|
@@ -777,8 +1141,8 @@ function detectAuth(files, sourceRoot, deps) {
|
|
|
777
1141
|
return {
|
|
778
1142
|
strategy: "firebase",
|
|
779
1143
|
lookup: `import { cookies } from "next/headers";
|
|
780
|
-
import { getAdminAuth } from "
|
|
781
|
-
import { SESSION_COOKIE } from "
|
|
1144
|
+
import { getAdminAuth } from "${ref("lib/firebase-admin")}";
|
|
1145
|
+
import { SESSION_COOKIE } from "${ref("lib/session")}";
|
|
782
1146
|
async function getCurrentUser() {
|
|
783
1147
|
const cookie = (await cookies()).get(SESSION_COOKIE)?.value;
|
|
784
1148
|
if (!cookie) return null;
|
|
@@ -792,7 +1156,7 @@ async function getCurrentUser() {
|
|
|
792
1156
|
if (authFile?.includes("NextAuth") && /export\s+(?:const|let)\s*\{[^}]*\bauth\b/.test(authFile)) {
|
|
793
1157
|
return {
|
|
794
1158
|
strategy: "nextauth",
|
|
795
|
-
lookup: `import { auth } from "
|
|
1159
|
+
lookup: `import { auth } from "${ref("auth")}";
|
|
796
1160
|
async function getCurrentUser() {
|
|
797
1161
|
const session = await auth();
|
|
798
1162
|
const user = session?.user as { id?: string; name?: string | null; email?: string | null } | undefined;
|
|
@@ -870,6 +1234,107 @@ function matchesAnyMatcher(matchers, path) {
|
|
|
870
1234
|
return new RegExp(`^${regexBody}$`).test(path);
|
|
871
1235
|
});
|
|
872
1236
|
}
|
|
1237
|
+
function insertImport(source, importLine) {
|
|
1238
|
+
const skip = [/^\s+/, /^\/\/[^\n]*/, /^\/\*[\s\S]*?\*\//];
|
|
1239
|
+
const keep = [/^["'][^"'\n]*["'][ \t]*;?/, /^import\s+(?:type\s+)?(?:[^"'`;]*?\sfrom\s*)?["'][^"'\n]+["'][ \t]*;?/];
|
|
1240
|
+
let at = 0;
|
|
1241
|
+
let afterPrologue = 0;
|
|
1242
|
+
let afterComments = 0;
|
|
1243
|
+
for (; ; ) {
|
|
1244
|
+
const rest = source.slice(at);
|
|
1245
|
+
const skipped = skip.map((pattern) => rest.match(pattern)?.[0]).find((hit) => hit);
|
|
1246
|
+
if (skipped) {
|
|
1247
|
+
at += skipped.length;
|
|
1248
|
+
if (!/^\s+$/.test(skipped) && afterPrologue === 0) afterComments = at;
|
|
1249
|
+
continue;
|
|
1250
|
+
}
|
|
1251
|
+
const kept = keep.map((pattern) => rest.match(pattern)?.[0]).find((hit) => hit);
|
|
1252
|
+
if (!kept) break;
|
|
1253
|
+
at += kept.length;
|
|
1254
|
+
afterPrologue = at;
|
|
1255
|
+
}
|
|
1256
|
+
const end = afterPrologue || afterComments;
|
|
1257
|
+
if (end === 0) return `${importLine}
|
|
1258
|
+
|
|
1259
|
+
${source}`;
|
|
1260
|
+
return `${source.slice(0, end)}
|
|
1261
|
+
${importLine}${source.slice(end)}`;
|
|
1262
|
+
}
|
|
1263
|
+
function mountWidget(layout, component = "FrankieWidgetMount") {
|
|
1264
|
+
const mount = `<${component} />`;
|
|
1265
|
+
for (const tag of ["</body>", "</main>"]) {
|
|
1266
|
+
const at = layout.indexOf(tag);
|
|
1267
|
+
if (at === -1) continue;
|
|
1268
|
+
const lineStart = layout.lastIndexOf("\n", at) + 1;
|
|
1269
|
+
const lead = layout.slice(lineStart, at);
|
|
1270
|
+
if (!/^[ \t]*$/.test(lead)) return `${layout.slice(0, at)}${mount}${layout.slice(at)}`;
|
|
1271
|
+
const unit = lead.includes(" ") ? " " : " ";
|
|
1272
|
+
return `${layout.slice(0, lineStart)}${lead}${unit}${mount}
|
|
1273
|
+
${layout.slice(lineStart)}`;
|
|
1274
|
+
}
|
|
1275
|
+
return layout.includes("{children}") ? layout.replace("{children}", `{children}${mount}`) : void 0;
|
|
1276
|
+
}
|
|
1277
|
+
function withDependencies(current, added) {
|
|
1278
|
+
const existing = current ?? {};
|
|
1279
|
+
const merged = { ...existing };
|
|
1280
|
+
for (const [name, range] of Object.entries(added)) merged[name] ??= range;
|
|
1281
|
+
const names = Object.keys(existing);
|
|
1282
|
+
const sorted = [...names].sort((a, b) => a.localeCompare(b, "en"));
|
|
1283
|
+
if (names.some((name, index) => name !== sorted[index])) return merged;
|
|
1284
|
+
return Object.fromEntries(Object.keys(merged).sort((a, b) => a.localeCompare(b, "en")).map((name) => [name, merged[name]]));
|
|
1285
|
+
}
|
|
1286
|
+
function renderJson(value, original) {
|
|
1287
|
+
const indent = original?.match(/^([ \t]+)"/m)?.[1] ?? " ";
|
|
1288
|
+
const text = JSON.stringify(value, null, indent);
|
|
1289
|
+
return original === void 0 || original.endsWith("\n") ? `${text}
|
|
1290
|
+
` : text;
|
|
1291
|
+
}
|
|
1292
|
+
function moduleImporter(files, root, sourceRoot) {
|
|
1293
|
+
const tsconfig = files[root + "tsconfig.json"];
|
|
1294
|
+
if (tsconfig === void 0) return (_from, module2) => `@/${module2}`;
|
|
1295
|
+
const alias = aliasFor(tsconfig, root, sourceRoot);
|
|
1296
|
+
if (alias !== void 0) return (_from, module2) => `${alias}${module2}`;
|
|
1297
|
+
return (from, module2) => relativeImport(from, sourceRoot + module2);
|
|
1298
|
+
}
|
|
1299
|
+
function aliasFor(tsconfig, root, sourceRoot) {
|
|
1300
|
+
let options;
|
|
1301
|
+
try {
|
|
1302
|
+
const parsed = JSON.parse(tsconfig.replace(/\/\*[\s\S]*?\*\//g, "").replace(/^\s*\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1"));
|
|
1303
|
+
options = parsed.compilerOptions;
|
|
1304
|
+
} catch {
|
|
1305
|
+
return void 0;
|
|
1306
|
+
}
|
|
1307
|
+
const paths = options?.paths;
|
|
1308
|
+
if (!paths || typeof paths !== "object") return void 0;
|
|
1309
|
+
const baseUrl = typeof options?.baseUrl === "string" ? options.baseUrl : ".";
|
|
1310
|
+
const target = normalizePath(sourceRoot);
|
|
1311
|
+
for (const [pattern, targets] of Object.entries(paths)) {
|
|
1312
|
+
if (!pattern.endsWith("/*") || !Array.isArray(targets)) continue;
|
|
1313
|
+
for (const entry of targets) {
|
|
1314
|
+
if (typeof entry !== "string" || !entry.endsWith("/*")) continue;
|
|
1315
|
+
if (normalizePath(`${root}${baseUrl}/${entry.slice(0, -1)}`) === target) return pattern.slice(0, -1);
|
|
1316
|
+
}
|
|
1317
|
+
}
|
|
1318
|
+
return void 0;
|
|
1319
|
+
}
|
|
1320
|
+
function normalizePath(path) {
|
|
1321
|
+
const out = [];
|
|
1322
|
+
for (const part of path.split("/")) {
|
|
1323
|
+
if (part === "" || part === ".") continue;
|
|
1324
|
+
if (part === "..") out.pop();
|
|
1325
|
+
else out.push(part);
|
|
1326
|
+
}
|
|
1327
|
+
return out.join("/") + "/";
|
|
1328
|
+
}
|
|
1329
|
+
function relativeImport(fromFile, module2) {
|
|
1330
|
+
const from = normalizePath(fromFile.slice(0, fromFile.lastIndexOf("/") + 1)).split("/").filter(Boolean);
|
|
1331
|
+
const to = module2.split("/").filter((part) => part !== "" && part !== ".");
|
|
1332
|
+
let common = 0;
|
|
1333
|
+
while (common < from.length && common < to.length && from[common] === to[common]) common += 1;
|
|
1334
|
+
const up = from.length - common;
|
|
1335
|
+
const rel = [...Array(up).fill(".."), ...to.slice(common)].join("/");
|
|
1336
|
+
return up === 0 ? `./${rel}` : rel;
|
|
1337
|
+
}
|
|
873
1338
|
|
|
874
1339
|
// src/sdk-updates.ts
|
|
875
1340
|
function sdkUpdateWorkflow(mode) {
|
|
@@ -1109,9 +1574,147 @@ function compareApiDocuments(before, after) {
|
|
|
1109
1574
|
unchangedIds: [...next].filter(([key, value]) => old.get(key)?.fingerprint === value.fingerprint).map(([, value]) => value.id)
|
|
1110
1575
|
};
|
|
1111
1576
|
}
|
|
1577
|
+
|
|
1578
|
+
// src/theme-infer.ts
|
|
1579
|
+
var hex2 = (value) => Math.round(Math.min(255, Math.max(0, value))).toString(16).padStart(2, "0");
|
|
1580
|
+
var toHex = (r, g, b) => "#" + hex2(r) + hex2(g) + hex2(b);
|
|
1581
|
+
function hslToHex(h, s, l) {
|
|
1582
|
+
const sat = s / 100, light = l / 100;
|
|
1583
|
+
const k = (n) => (n + h / 30) % 12;
|
|
1584
|
+
const a = sat * Math.min(light, 1 - light);
|
|
1585
|
+
const f = (n) => light - a * Math.max(-1, Math.min(k(n) - 3, Math.min(9 - k(n), 1)));
|
|
1586
|
+
return toHex(f(0) * 255, f(8) * 255, f(4) * 255);
|
|
1587
|
+
}
|
|
1588
|
+
function oklchToHex(l, c, hDeg) {
|
|
1589
|
+
const h = hDeg * Math.PI / 180;
|
|
1590
|
+
const a = c * Math.cos(h), b = c * Math.sin(h);
|
|
1591
|
+
const l_ = (l + 0.3963377774 * a + 0.2158037573 * b) ** 3;
|
|
1592
|
+
const m_ = (l - 0.1055613458 * a - 0.0638541728 * b) ** 3;
|
|
1593
|
+
const s_ = (l - 0.0894841775 * a - 1.291485548 * b) ** 3;
|
|
1594
|
+
const linear = [
|
|
1595
|
+
4.0767416621 * l_ - 3.3077115913 * m_ + 0.2309699292 * s_,
|
|
1596
|
+
-1.2684380046 * l_ + 2.6097574011 * m_ - 0.3413193965 * s_,
|
|
1597
|
+
-0.0041960863 * l_ - 0.7034186147 * m_ + 1.707614701 * s_
|
|
1598
|
+
];
|
|
1599
|
+
const gamma = (x) => (x <= 31308e-7 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055) * 255;
|
|
1600
|
+
return toHex(gamma(linear[0]), gamma(linear[1]), gamma(linear[2]));
|
|
1601
|
+
}
|
|
1602
|
+
var NUMBER = String.raw`-?\d*\.?\d+`;
|
|
1603
|
+
var parts = (body) => body.replace(/\/.*$/, "").split(/[\s,]+/).filter(Boolean);
|
|
1604
|
+
function normalizeColor(raw) {
|
|
1605
|
+
const value = raw.trim().replace(/\s*!important$/, "").toLowerCase();
|
|
1606
|
+
let match = value.match(/^#([0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/);
|
|
1607
|
+
if (match) {
|
|
1608
|
+
const digits = match[1];
|
|
1609
|
+
return digits.length <= 4 ? "#" + [...digits.slice(0, 3)].map((d) => d + d).join("") : "#" + digits.slice(0, 6);
|
|
1610
|
+
}
|
|
1611
|
+
match = value.match(/^rgba?\(([^)]*)\)$/);
|
|
1612
|
+
if (match) {
|
|
1613
|
+
const [r, g, b] = parts(match[1]).map((part) => part.endsWith("%") ? parseFloat(part) * 2.55 : parseFloat(part));
|
|
1614
|
+
return [r, g, b].every((n) => Number.isFinite(n)) ? toHex(r, g, b) : void 0;
|
|
1615
|
+
}
|
|
1616
|
+
match = value.match(/^hsla?\(([^)]*)\)$/) ?? value.match(new RegExp(`^(${NUMBER}(?:deg)?\\s+${NUMBER}%\\s+${NUMBER}%)$`));
|
|
1617
|
+
if (match) {
|
|
1618
|
+
const [h, s, l] = parts(match[1]).map((part) => parseFloat(part));
|
|
1619
|
+
return [h, s, l].every((n) => Number.isFinite(n)) ? hslToHex(h, s, l) : void 0;
|
|
1620
|
+
}
|
|
1621
|
+
match = value.match(/^oklch\(([^)]*)\)$/);
|
|
1622
|
+
if (match) {
|
|
1623
|
+
const [lRaw, cRaw, hRaw] = parts(match[1]);
|
|
1624
|
+
const l = lRaw?.endsWith("%") ? parseFloat(lRaw) / 100 : parseFloat(lRaw ?? "");
|
|
1625
|
+
const c = cRaw?.endsWith("%") ? parseFloat(cRaw) / 100 * 0.4 : parseFloat(cRaw ?? "");
|
|
1626
|
+
const h = hRaw === void 0 || hRaw === "none" ? 0 : parseFloat(hRaw);
|
|
1627
|
+
return [l, c, h].every((n) => Number.isFinite(n)) ? oklchToHex(l, c, h) : void 0;
|
|
1628
|
+
}
|
|
1629
|
+
return void 0;
|
|
1630
|
+
}
|
|
1631
|
+
function rootVariables(css) {
|
|
1632
|
+
const vars = /* @__PURE__ */ new Map();
|
|
1633
|
+
const source = css.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
1634
|
+
const blocks = /([^{}]*)\{([^{}]*)\}/g;
|
|
1635
|
+
for (let block = blocks.exec(source); block; block = blocks.exec(source)) {
|
|
1636
|
+
const selector = block[1].split(";").pop().trim();
|
|
1637
|
+
if (!/(?:^|[\s,]):root\b(?![^,]*\.dark)|^@theme\b/.test(selector) || /\.dark|\[data-theme/.test(selector)) continue;
|
|
1638
|
+
for (const declaration of block[2].split(";")) {
|
|
1639
|
+
const found = declaration.match(/^\s*(--[\w-]+)\s*:\s*([\s\S]+?)\s*$/);
|
|
1640
|
+
if (found && !vars.has(found[1])) vars.set(found[1], found[2]);
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
return vars;
|
|
1644
|
+
}
|
|
1645
|
+
function resolve(value, vars, depth = 0) {
|
|
1646
|
+
if (depth > 4) return void 0;
|
|
1647
|
+
const reference = value.match(/^(?:(hsla?|oklch|rgba?)\(\s*)?var\(\s*(--[\w-]+)\s*(?:,[^)]*)?\)\s*\)?$/i);
|
|
1648
|
+
if (!reference) return normalizeColor(value);
|
|
1649
|
+
const inner = vars.get(reference[2]);
|
|
1650
|
+
if (!inner) return void 0;
|
|
1651
|
+
const wrapper = reference[1]?.toLowerCase();
|
|
1652
|
+
return wrapper && !/^\s*(?:#|[a-z]+\()/i.test(inner) ? normalizeColor(wrapper + "(" + inner + ")") : resolve(inner, vars, depth + 1);
|
|
1653
|
+
}
|
|
1654
|
+
function radiusOption(value) {
|
|
1655
|
+
const match = value.trim().match(/^(\d*\.?\d+)(rem|px)?$/);
|
|
1656
|
+
if (!match) return void 0;
|
|
1657
|
+
const rem = match[2] === "px" ? parseFloat(match[1]) / 16 : parseFloat(match[1]);
|
|
1658
|
+
return rem < 0.525 ? "0.35" : rem < 0.9 ? "0.7" : "1.1";
|
|
1659
|
+
}
|
|
1660
|
+
var CSS_PRIORITY = [/(?:^|\/)app\/globals\.css$/, /(?:^|\/)styles\/globals\.css$/, /globals\.css$/, /\.css$/];
|
|
1661
|
+
var PRIMARY_VARS = ["--primary", "--color-primary", "--brand", "--color-brand", "--accent", "--color-accent"];
|
|
1662
|
+
function rank(path) {
|
|
1663
|
+
const index = CSS_PRIORITY.findIndex((pattern) => pattern.test(path));
|
|
1664
|
+
return index === -1 ? CSS_PRIORITY.length : index;
|
|
1665
|
+
}
|
|
1666
|
+
function inferTheme(files, root = "") {
|
|
1667
|
+
const inRoot = (path) => path.startsWith(root) && !/(?:^|\/)(?:node_modules|dist|\.next|vendor|coverage)\//.test(path);
|
|
1668
|
+
const theme = {};
|
|
1669
|
+
const sources = [];
|
|
1670
|
+
const set = (field, value, path) => {
|
|
1671
|
+
if (!value || theme[field]) return;
|
|
1672
|
+
theme[field] = value;
|
|
1673
|
+
sources.push({ field, path });
|
|
1674
|
+
};
|
|
1675
|
+
const cssFiles = Object.keys(files).filter((path) => inRoot(path) && path.endsWith(".css")).sort((a, b) => rank(a) - rank(b) || a.localeCompare(b));
|
|
1676
|
+
const vars = /* @__PURE__ */ new Map();
|
|
1677
|
+
const varPath = /* @__PURE__ */ new Map();
|
|
1678
|
+
for (const path of cssFiles) {
|
|
1679
|
+
for (const [name, value] of rootVariables(files[path])) if (!vars.has(name)) {
|
|
1680
|
+
vars.set(name, value);
|
|
1681
|
+
varPath.set(name, path);
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
for (const name of PRIMARY_VARS) if (vars.has(name)) set("primary", resolve(vars.get(name), vars), varPath.get(name));
|
|
1685
|
+
for (const name of ["--background", "--color-background"]) if (vars.has(name)) set("background", resolve(vars.get(name), vars), varPath.get(name));
|
|
1686
|
+
for (const name of ["--foreground", "--color-foreground"]) if (vars.has(name)) set("foreground", resolve(vars.get(name), vars), varPath.get(name));
|
|
1687
|
+
if (vars.has("--radius")) set("radius", radiusOption(vars.get("--radius")), varPath.get("--radius"));
|
|
1688
|
+
for (const path of Object.keys(files).filter((p) => inRoot(p) && /(?:^|\/)tailwind\.config\.[cm]?[jt]s$/.test(p)).sort()) {
|
|
1689
|
+
const source = files[path];
|
|
1690
|
+
for (const key of ["primary", "brand", "accent"]) {
|
|
1691
|
+
const direct = source.match(new RegExp(`["']?${key}["']?\\s*:\\s*["'\`]([^"'\`]+)["'\`]`));
|
|
1692
|
+
const nested = source.match(new RegExp(`["']?${key}["']?\\s*:\\s*\\{[^}]*?DEFAULT\\s*:\\s*["'\`]([^"'\`]+)["'\`]`));
|
|
1693
|
+
const raw = direct?.[1] ?? nested?.[1];
|
|
1694
|
+
if (raw) set("primary", resolve(raw, vars), path);
|
|
1695
|
+
}
|
|
1696
|
+
}
|
|
1697
|
+
for (const path of Object.keys(files).filter((p) => inRoot(p) && /(?:^|\/)app\/(?:\([^/]+\)\/)?layout\.tsx$/.test(p)).sort()) {
|
|
1698
|
+
const raw = files[path].match(/themeColor\s*:\s*(?:\[\s*\{[^}]*?color\s*:\s*)?["'`]([^"'`]+)["'`]/)?.[1];
|
|
1699
|
+
if (raw) set("primary", normalizeColor(raw), path);
|
|
1700
|
+
}
|
|
1701
|
+
for (const path of Object.keys(files).filter((p) => inRoot(p) && /(?:^|\/)(?:manifest\.json|site\.webmanifest|manifest\.webmanifest)$/.test(p)).sort()) {
|
|
1702
|
+
try {
|
|
1703
|
+
const manifest = JSON.parse(files[path]);
|
|
1704
|
+
if (typeof manifest.theme_color === "string") set("primary", normalizeColor(manifest.theme_color), path);
|
|
1705
|
+
if (typeof manifest.background_color === "string") set("background", normalizeColor(manifest.background_color), path);
|
|
1706
|
+
} catch {
|
|
1707
|
+
}
|
|
1708
|
+
}
|
|
1709
|
+
if (!theme.primary) return void 0;
|
|
1710
|
+
return { theme: { ...theme, primary: theme.primary }, sources };
|
|
1711
|
+
}
|
|
1112
1712
|
// Annotate the CommonJS export names for ESM import in node:
|
|
1113
1713
|
0 && (module.exports = {
|
|
1114
1714
|
FIREWORKS_MODELS,
|
|
1715
|
+
SKILL_TOOL_NAME,
|
|
1716
|
+
STANDARD_AGENT_IDS,
|
|
1717
|
+
STARTER_PROMPT_ICONS,
|
|
1115
1718
|
activeSkills,
|
|
1116
1719
|
agentConfigSchema,
|
|
1117
1720
|
apiCapabilitySchema,
|
|
@@ -1130,14 +1733,32 @@ function compareApiDocuments(before, after) {
|
|
|
1130
1733
|
evaluateToolPolicy,
|
|
1131
1734
|
featuresConfigSchema,
|
|
1132
1735
|
frankieConfigSchema,
|
|
1736
|
+
inferTheme,
|
|
1737
|
+
insertImport,
|
|
1738
|
+
installAgentIds,
|
|
1739
|
+
installBranch,
|
|
1740
|
+
installTitle,
|
|
1741
|
+
knowledgeBaseSchema,
|
|
1742
|
+
knowledgeSourceSchema,
|
|
1133
1743
|
limitsConfigSchema,
|
|
1134
1744
|
matchPath,
|
|
1745
|
+
matchesAnyPagePattern,
|
|
1746
|
+
matchesInstallBranch,
|
|
1747
|
+
matchesPagePattern,
|
|
1135
1748
|
mcpServerSchema,
|
|
1136
1749
|
modelChoiceSchema,
|
|
1137
1750
|
modelConfigSchema,
|
|
1751
|
+
moduleImporter,
|
|
1752
|
+
mountWidget,
|
|
1753
|
+
normalizeColor,
|
|
1138
1754
|
operationId,
|
|
1139
1755
|
parseFrankieConfig,
|
|
1756
|
+
personaEnvKey,
|
|
1757
|
+
personaProfileSchema,
|
|
1758
|
+
plainText,
|
|
1140
1759
|
planHostedInstall,
|
|
1760
|
+
renderJson,
|
|
1761
|
+
resolvePersonaConfig,
|
|
1141
1762
|
sdkUpdateWorkflow,
|
|
1142
1763
|
skillSchema,
|
|
1143
1764
|
starterPromptSchema,
|
|
@@ -1146,6 +1767,7 @@ function compareApiDocuments(before, after) {
|
|
|
1146
1767
|
toolDescriptions,
|
|
1147
1768
|
toolPolicySchema,
|
|
1148
1769
|
validateApiDocument,
|
|
1149
|
-
widgetThemeSchema
|
|
1770
|
+
widgetThemeSchema,
|
|
1771
|
+
withDependencies
|
|
1150
1772
|
});
|
|
1151
1773
|
//# sourceMappingURL=index.cjs.map
|