@graph8/sdk 0.362.0 → 0.370.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +14 -10
- package/blocks/_foundation/README.md +64 -1
- package/blocks/_foundation/block.json +79 -6
- package/blocks/_foundation/components/api-error.tsx +9 -7
- package/blocks/_foundation/components/brand-provider.tsx +23 -0
- package/blocks/_foundation/components/confirmed-action.tsx +90 -0
- package/blocks/_foundation/components/connect-graph8.tsx +280 -0
- package/blocks/_foundation/components/screen/index.ts +1 -0
- package/blocks/_foundation/components/screen/record-link.tsx +76 -0
- package/blocks/_foundation/components/screen/time-series-chart-recharts.tsx +65 -0
- package/blocks/_foundation/components/screen/time-series-chart.tsx +15 -97
- package/blocks/_foundation/components/screen/time-series-format.ts +57 -0
- package/blocks/_foundation/hooks/use-brand.ts +52 -0
- package/blocks/_foundation/hooks/use-g8-mutation.ts +23 -0
- package/blocks/_foundation/hooks/use-g8-query.ts +127 -1
- package/blocks/_foundation/hooks/use-paginated.ts +120 -0
- package/blocks/_foundation/hooks/use-row-activation.ts +57 -0
- package/blocks/_foundation/lib/g8-cache.ts +454 -0
- package/blocks/_foundation/lib/g8-client.ts +1 -1
- package/blocks/_foundation/lib/plural.ts +23 -0
- package/blocks/_foundation/server/connect-route.ts +15 -0
- package/blocks/_foundation/server/connect.ts +127 -0
- package/blocks/_foundation/server/local-key.ts +70 -0
- package/blocks/_foundation/server/proxy.ts +103 -3
- package/blocks/_foundation/server/route.ts +16 -4
- package/blocks/agents/README.md +14 -0
- package/blocks/agents/block.json +33 -1
- package/blocks/agents/components/agent-builder.tsx +13 -8
- package/blocks/agents/components/agent-knowledge-panel.tsx +2 -1
- package/blocks/agents/components/agent-run-detail.tsx +102 -0
- package/blocks/agents/components/agent-runs.tsx +16 -7
- package/blocks/agents/components/agents-screen.tsx +184 -0
- package/blocks/analytics/README.md +13 -0
- package/blocks/analytics/block.json +45 -1
- package/blocks/analytics/components/analytics-screen.tsx +209 -0
- package/blocks/analytics/components/leaderboard.tsx +14 -7
- package/blocks/analytics/components/report-builder.tsx +23 -9
- package/blocks/analytics/components/report-detail.tsx +84 -0
- package/blocks/analytics/components/trend-chart.tsx +62 -0
- package/blocks/appointments/block.json +6 -2
- package/blocks/appointments/components/availability-editor.tsx +17 -5
- package/blocks/appointments/components/event-type-editor.tsx +2 -1
- package/blocks/appointments/components/event-type-related.tsx +4 -2
- package/blocks/appointments/components/meeting-list.tsx +9 -5
- package/blocks/appointments/components/meetings-screen.tsx +7 -4
- package/blocks/appointments/components/scheduling-screen.tsx +2 -1
- package/blocks/block.schema.json +18 -0
- package/blocks/block.types.ts +17 -0
- package/blocks/campaigns/README.md +41 -11
- package/blocks/campaigns/block.json +105 -42
- package/blocks/campaigns/components/campaign-assets.tsx +16 -5
- package/blocks/campaigns/components/campaign-launch-step.tsx +102 -19
- package/blocks/campaigns/components/campaign-list.tsx +7 -3
- package/blocks/campaigns/components/campaign-wizard.tsx +43 -10
- package/blocks/campaigns/components/campaigns-screen.tsx +267 -0
- package/blocks/campaigns/components/icp-detail.tsx +154 -0
- package/blocks/campaigns/components/icp-editor.tsx +15 -15
- package/blocks/campaigns/components/persona-detail.tsx +172 -0
- package/blocks/campaigns/components/persona-editor.tsx +14 -11
- package/blocks/campaigns/lib/campaigns-utils.ts +134 -1
- package/blocks/campaigns/recipes/attach-audience-and-launch.ts +16 -3
- package/blocks/campaigns/recipes/generate-ideas-and-create-campaign.ts +8 -4
- package/blocks/coach/README.md +10 -0
- package/blocks/coach/block.json +16 -1
- package/blocks/coach/components/coach-screen.tsx +222 -0
- package/blocks/copilot/README.md +13 -0
- package/blocks/copilot/block.json +14 -1
- package/blocks/copilot/components/copilot-panel.tsx +14 -5
- package/blocks/copilot/components/copilot-screen.tsx +148 -0
- package/blocks/copilot/components/copilot-team-chat.tsx +7 -5
- package/blocks/crm/README.md +28 -0
- package/blocks/crm/block.json +58 -2
- package/blocks/crm/components/companies-screen.tsx +345 -0
- package/blocks/crm/components/company-detail.tsx +8 -6
- package/blocks/crm/components/company-table.tsx +11 -4
- package/blocks/crm/components/contact-detail.tsx +6 -3
- package/blocks/crm/components/contact-table.tsx +10 -3
- package/blocks/crm/components/contacts-screen.tsx +359 -0
- package/blocks/crm/components/dedupe-review.tsx +7 -5
- package/blocks/crm/components/import-wizard.tsx +4 -2
- package/blocks/crm/components/notes-panel.tsx +4 -4
- package/blocks/crm/components/record-form.tsx +5 -3
- package/blocks/crm/components/task-list.tsx +4 -4
- package/blocks/crm/components/tasks-screen.tsx +379 -0
- package/blocks/crm/hooks/use-custom-fields.ts +6 -1
- package/blocks/crm/hooks/use-page-query.ts +7 -1
- package/blocks/crm/lib/crm-types.ts +12 -2
- package/blocks/crm/lib/screen-parts.tsx +105 -0
- package/blocks/deals/block.json +9 -3
- package/blocks/deals/components/deal-detail.tsx +23 -11
- package/blocks/deals/components/deal-related.tsx +1 -1
- package/blocks/deals/components/deals-screen.tsx +10 -5
- package/blocks/deals/components/stage-editor.tsx +4 -4
- package/blocks/deals/hooks/use-pipeline-deals.ts +5 -1
- package/blocks/deals/hooks/use-pipelines.ts +1 -1
- package/blocks/dialer/README.md +34 -2
- package/blocks/dialer/block.json +67 -1
- package/blocks/dialer/components/call-contact-button.tsx +252 -0
- package/blocks/dialer/components/call-log-screen.tsx +205 -0
- package/blocks/dialer/components/call-log.tsx +7 -3
- package/blocks/dialer/components/call-queue.tsx +1 -1
- package/blocks/dialer/components/dialer-screen.tsx +319 -0
- package/blocks/dialer/components/dialer-session-detail.tsx +153 -0
- package/blocks/dialer/components/live-calls-table.tsx +7 -3
- package/blocks/dialer/components/session-start-form.tsx +4 -2
- package/blocks/dialer/components/softphone.tsx +3 -1
- package/blocks/enrichment/README.md +14 -2
- package/blocks/enrichment/block.json +32 -4
- package/blocks/enrichment/components/bulk-enrich-panel.tsx +10 -5
- package/blocks/enrichment/components/email-verify-badge.tsx +28 -8
- package/blocks/enrichment/components/enrich-button.tsx +32 -9
- package/blocks/enrichment/components/enrichment-job-detail.tsx +78 -0
- package/blocks/enrichment/components/enrichment-screen.tsx +288 -0
- package/blocks/enrichment/components/waterfall-config.tsx +39 -10
- package/blocks/enrichment/recipes/waterfall-pipeline.ts +3 -2
- package/blocks/forms/block.json +32 -18
- package/blocks/forms/components/form-builder.tsx +3 -1
- package/blocks/forms/components/form-embed.tsx +3 -1
- package/blocks/forms/components/forms-screen.tsx +5 -2
- package/blocks/forms/components/submissions-table.tsx +3 -1
- package/blocks/inbox/README.md +27 -3
- package/blocks/inbox/block.json +14 -2
- package/blocks/inbox/components/inbox-list.tsx +7 -3
- package/blocks/inbox/components/inbox-screen.tsx +294 -27
- package/blocks/inbox/components/reply-composer.tsx +65 -9
- package/blocks/inbox/components/tag-and-assign.tsx +4 -2
- package/blocks/inbox/hooks/use-inbox-thread.ts +1 -1
- package/blocks/inbox/hooks/use-inbox-threads.ts +1 -0
- package/blocks/inbox/lib/inbox-types.ts +47 -1
- package/blocks/inbox/recipes/01-triage-and-reply.ts +12 -2
- package/blocks/integrations/README.md +9 -0
- package/blocks/integrations/block.json +46 -1
- package/blocks/integrations/components/crm-detail.tsx +100 -0
- package/blocks/integrations/components/integration-list.tsx +9 -4
- package/blocks/integrations/components/integrations-screen.tsx +135 -0
- package/blocks/integrations/components/sync-status.tsx +5 -3
- package/blocks/integrations/components/webhook-detail.tsx +160 -0
- package/blocks/integrations/components/webhook-manager.tsx +13 -8
- package/blocks/integrations/lib/integrations-utils.ts +6 -1
- package/blocks/intent/README.md +14 -0
- package/blocks/intent/block.json +27 -1
- package/blocks/intent/components/intent-feed.tsx +5 -2
- package/blocks/intent/components/intent-keyword-manager.tsx +8 -4
- package/blocks/intent/components/intent-screen.tsx +265 -0
- package/blocks/intent/components/keyword-detail.tsx +124 -0
- package/blocks/lifecycle/block.json +13 -8
- package/blocks/lifecycle/components/lifecycle-board.tsx +3 -1
- package/blocks/lifecycle/components/lifecycle-screen.tsx +3 -1
- package/blocks/lifecycle/lib/lifecycle.ts +1 -1
- package/blocks/lists/README.md +13 -0
- package/blocks/lists/block.json +39 -1
- package/blocks/lists/components/add-to-list-button.tsx +3 -2
- package/blocks/lists/components/list-picker.tsx +5 -1
- package/blocks/lists/components/list-table.tsx +11 -5
- package/blocks/lists/components/lists-screen.tsx +344 -0
- package/blocks/lists/components/suppression-manager.tsx +6 -2
- package/blocks/lists/components/suppressions-screen.tsx +255 -0
- package/blocks/mailboxes/README.md +17 -0
- package/blocks/mailboxes/block.json +65 -17
- package/blocks/mailboxes/components/connect-mailbox.tsx +23 -13
- package/blocks/mailboxes/components/domain-detail.tsx +142 -0
- package/blocks/mailboxes/components/domain-purchase.tsx +6 -3
- package/blocks/mailboxes/components/mailbox-detail.tsx +156 -0
- package/blocks/mailboxes/components/mailboxes-screen.tsx +223 -0
- package/blocks/mailboxes/components/warmup-health.tsx +4 -2
- package/blocks/mailboxes/lib/mailboxes-utils.ts +22 -1
- package/blocks/newsletter/block.json +40 -24
- package/blocks/newsletter/components/issue-scheduler.tsx +2 -1
- package/blocks/newsletter/components/newsletter-screen.tsx +3 -1
- package/blocks/nurture/block.json +28 -14
- package/blocks/nurture/components/nurture-builder.tsx +3 -1
- package/blocks/nurture/components/nurture-enroll.tsx +6 -3
- package/blocks/nurture/components/nurture-form.tsx +23 -3
- package/blocks/nurture/components/nurture-screen.tsx +10 -6
- package/blocks/nurture/lib/nurture-types.ts +1 -1
- package/blocks/objects/README.md +17 -0
- package/blocks/objects/block.json +29 -16
- package/blocks/objects/components/object-not-found.tsx +3 -1
- package/blocks/objects/components/object-record-detail.tsx +2 -1
- package/blocks/objects/components/object-schema-editor.tsx +3 -1
- package/blocks/objects/components/object-table.tsx +13 -3
- package/blocks/objects/components/objects-screen.tsx +147 -0
- package/blocks/objects/lib/object-types.ts +1 -1
- package/blocks/pages/block.json +28 -23
- package/blocks/pages/components/brand-kit-editor.tsx +5 -2
- package/blocks/pages/components/content-library.tsx +9 -4
- package/blocks/pages/components/page-list.tsx +2 -1
- package/blocks/quotes/block.json +17 -12
- package/blocks/quotes/components/product-picker.tsx +3 -1
- package/blocks/quotes/components/quote-related.tsx +5 -2
- package/blocks/radar/README.md +10 -0
- package/blocks/radar/block.json +41 -1
- package/blocks/radar/components/competitor-detail.tsx +117 -0
- package/blocks/radar/components/competitor-tracker.tsx +3 -1
- package/blocks/radar/components/radar-report.tsx +4 -2
- package/blocks/radar/components/radar-screen.tsx +214 -0
- package/blocks/radar/components/report-detail.tsx +104 -0
- package/blocks/search/README.md +10 -0
- package/blocks/search/block.json +16 -1
- package/blocks/search/components/filter-builder.tsx +3 -3
- package/blocks/search/components/prospect-results.tsx +5 -3
- package/blocks/search/components/prospect-search-screen.tsx +235 -0
- package/blocks/search/components/saved-searches.tsx +4 -1
- package/blocks/search/hooks/use-filter-options.ts +20 -6
- package/blocks/search/lib/search-types.ts +2 -0
- package/blocks/sequencer/README.md +49 -9
- package/blocks/sequencer/block.json +99 -19
- package/blocks/sequencer/components/enroll-dialog.tsx +9 -3
- package/blocks/sequencer/components/sequence-builder.tsx +211 -46
- package/blocks/sequencer/components/sequence-detail.tsx +259 -0
- package/blocks/sequencer/components/sequence-enrollments.tsx +114 -0
- package/blocks/sequencer/components/sequence-list.tsx +33 -8
- package/blocks/sequencer/components/sequence-stats.tsx +3 -2
- package/blocks/sequencer/components/sequence-steps-panel.tsx +103 -0
- package/blocks/sequencer/components/sequences-screen.tsx +159 -0
- package/blocks/sequencer/components/step-editor.tsx +3 -1
- package/blocks/sequencer/lib/sequencer-utils.ts +128 -5
- package/blocks/sequencer/recipes/create-multichannel-sequence.ts +33 -5
- package/blocks/sequencer/recipes/launch-and-enroll.ts +19 -2
- package/blocks/social-ads/README.md +11 -0
- package/blocks/social-ads/block.json +101 -2
- package/blocks/social-ads/components/ad-campaign-list.tsx +9 -5
- package/blocks/social-ads/components/ads-screen.tsx +152 -0
- package/blocks/social-ads/components/audience-sync-detail.tsx +188 -0
- package/blocks/social-ads/components/audience-sync.tsx +11 -7
- package/blocks/social-ads/components/campaign-detail.tsx +133 -0
- package/blocks/social-ads/components/creative-detail.tsx +150 -0
- package/blocks/social-ads/components/creative-gallery.tsx +15 -5
- package/blocks/social-ads/components/post-composer.tsx +3 -1
- package/blocks/social-ads/components/post-detail.tsx +160 -0
- package/blocks/social-ads/components/social-post-list.tsx +11 -3
- package/blocks/social-ads/components/social-screen.tsx +160 -0
- package/blocks/visitors/README.md +10 -0
- package/blocks/visitors/block.json +25 -1
- package/blocks/visitors/components/snippet-install.tsx +3 -1
- package/blocks/visitors/components/visitor-company-card.tsx +2 -2
- package/blocks/visitors/components/visitor-detail.tsx +105 -0
- package/blocks/visitors/components/visitor-feed.tsx +11 -2
- package/blocks/visitors/components/visitors-screen.tsx +166 -0
- package/blocks/webchat/README.md +10 -0
- package/blocks/webchat/block.json +21 -7
- package/blocks/webchat/components/chat-widget-config.tsx +7 -4
- package/blocks/webchat/components/conversation-list.tsx +7 -3
- package/blocks/webchat/components/webchat-screen.tsx +245 -0
- package/blocks/workbench/README.md +11 -0
- package/blocks/workbench/block.json +48 -1
- package/blocks/workbench/components/column-recipe-editor.tsx +4 -2
- package/blocks/workbench/components/workbench-grid.tsx +9 -3
- package/blocks/workbench/components/workbench-row-detail.tsx +104 -0
- package/blocks/workbench/components/workbench-screen.tsx +192 -0
- package/blocks/workbench/components/workbench-sources.tsx +194 -0
- package/blocks/workflows/README.md +13 -0
- package/blocks/workflows/block.json +28 -9
- package/blocks/workflows/components/run-history.tsx +31 -9
- package/blocks/workflows/components/trigger-config.tsx +3 -1
- package/blocks/workflows/components/workflow-editor.tsx +3 -1
- package/blocks/workflows/components/workflow-list.tsx +13 -6
- package/blocks/workflows/components/workflows-screen.tsx +193 -0
- package/dist/cli.js +2709 -1721
- package/dist/errors.d.mts +34 -0
- package/dist/errors.d.ts +34 -0
- package/dist/errors.js +46 -0
- package/dist/errors.js.map +1 -0
- package/dist/errors.mjs +21 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/index.browser.js +11 -5
- package/dist/index.browser.js.map +1 -1
- package/dist/index.browser.mjs +11 -5
- package/dist/index.browser.mjs.map +1 -1
- package/dist/index.d.mts +164 -25
- package/dist/index.d.ts +164 -25
- package/dist/index.js +11 -5
- package/dist/index.js.map +1 -1
- package/dist/index.mjs +11 -5
- package/dist/index.mjs.map +1 -1
- package/dist/react.browser.js +10 -4
- package/dist/react.browser.js.map +1 -1
- package/dist/react.browser.mjs +10 -4
- package/dist/react.browser.mjs.map +1 -1
- package/dist/react.d.mts +142 -5
- package/dist/react.d.ts +142 -5
- package/dist/react.js +10 -4
- package/dist/react.js.map +1 -1
- package/dist/react.mjs +10 -4
- package/dist/react.mjs.map +1 -1
- package/dist/types/blocks/add.d.ts +11 -4
- package/dist/types/blocks/brand.d.ts +113 -0
- package/dist/types/blocks/compose.d.ts +53 -0
- package/dist/types/blocks/create.d.ts +22 -19
- package/dist/types/blocks/doctor.d.ts +5 -1
- package/dist/types/blocks/navfile.d.ts +35 -0
- package/dist/types/blocks/paths.d.ts +4 -0
- package/dist/types/blocks/prefixes.d.ts +37 -0
- package/dist/types/blocks/registry.d.ts +17 -0
- package/dist/types/blocks/screens.d.ts +53 -0
- package/dist/types/cli.d.ts +2 -0
- package/dist/types/errors.d.ts +33 -0
- package/dist/types/http.d.ts +2 -18
- package/dist/types/version.d.ts +2 -2
- package/docs/agents.md +15 -4
- package/docs/api/README.md +2 -2
- package/docs/api/by-path.md +1 -0
- package/docs/api/desk.d.ts +98 -2
- package/docs/api/desk.md +123 -1
- package/docs/api/inbox.d.ts +10 -2
- package/docs/api/inbox.md +10 -2
- package/docs/api/seo.d.ts +19 -1
- package/docs/api/seo.md +24 -1
- package/package.json +16 -2
- package/stacks/6sense.json +41 -16
- package/stacks/apollo/pages/companies.tsx.tmpl +50 -0
- package/stacks/apollo/pages/contacts.tsx.tmpl +117 -0
- package/stacks/apollo.json +118 -36
- package/stacks/calendly.json +60 -0
- package/stacks/clay/pages/companies.tsx.tmpl +15 -0
- package/stacks/clay/pages/contacts.tsx.tmpl +19 -0
- package/stacks/clay.json +59 -26
- package/stacks/gong.json +81 -35
- package/stacks/hootsuite.json +99 -0
- package/stacks/hubspot/pages/companies.tsx.tmpl +47 -0
- package/stacks/hubspot/pages/contacts.tsx.tmpl +111 -0
- package/stacks/hubspot/pages/deals.tsx.tmpl +11 -0
- package/stacks/hubspot/pages/inbox.tsx.tmpl +92 -0
- package/stacks/hubspot/pages/quotes.tsx.tmpl +11 -0
- package/stacks/hubspot.json +167 -66
- package/stacks/intercom.json +48 -20
- package/stacks/mailchimp.json +52 -33
- package/stacks/outreach/pages/contacts.tsx.tmpl +85 -0
- package/stacks/outreach.json +88 -25
- package/stacks/pipedrive/pages/companies.tsx.tmpl +47 -0
- package/stacks/pipedrive/pages/contacts.tsx.tmpl +47 -0
- package/stacks/pipedrive/pages/deals.tsx.tmpl +11 -0
- package/stacks/pipedrive/pages/inbox.tsx.tmpl +92 -0
- package/stacks/pipedrive/pages/quotes.tsx.tmpl +11 -0
- package/stacks/pipedrive.json +115 -0
- package/stacks/salesforce/pages/companies.tsx.tmpl +47 -0
- package/stacks/salesforce/pages/contacts.tsx.tmpl +47 -0
- package/stacks/salesforce/pages/deals.tsx.tmpl +11 -0
- package/stacks/salesforce/pages/quotes.tsx.tmpl +11 -0
- package/stacks/salesforce.json +81 -31
- package/stacks/zoominfo/pages/companies.tsx.tmpl +15 -0
- package/stacks/zoominfo/pages/contacts.tsx.tmpl +19 -0
- package/stacks/zoominfo.json +102 -0
- package/styles/styles.css +1 -1
- package/templates/next-app/README.md.tmpl +40 -9
- package/templates/next-app/app/layout.tsx.tmpl +7 -2
- package/templates/next-app/components/app-shell.tsx.tmpl +99 -89
- package/templates/next-app/dot-gitignore.tmpl +2 -0
- package/templates/next-app/package.json.tmpl +1 -1
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The graph8 API key for local preview, kept on the SERVER: the Connect graph8 screen posts a pasted key to
|
|
3
|
+
* `app/api/g8-connect/route.ts`, which validates it and stores it here, in `.g8/local-key` at the project root
|
|
4
|
+
* (file mode 0600, folder 0700, with its own `.gitignore` so it is never committed). The browser never holds it.
|
|
5
|
+
*
|
|
6
|
+
* resolveApiKey(): G8_API_KEY from the environment wins; otherwise, outside production, the stored key.
|
|
7
|
+
*
|
|
8
|
+
* Node only (reads the file system); the Next route that imports it runs on the Node runtime (the default).
|
|
9
|
+
* In a production build the file is never read: set G8_API_KEY in the deployment's environment.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { promises as fs } from "node:fs";
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
|
|
15
|
+
/** Where the key lives, relative to the project root (the directory `next dev` runs in). */
|
|
16
|
+
export const LOCAL_KEY_FILE = path.join(".g8", "local-key");
|
|
17
|
+
|
|
18
|
+
export type KeySource = "env" | "file";
|
|
19
|
+
|
|
20
|
+
const isProduction = (): boolean => process.env.NODE_ENV === "production";
|
|
21
|
+
|
|
22
|
+
function keyPath(root: string = process.cwd()): string {
|
|
23
|
+
return path.join(root, LOCAL_KEY_FILE);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** The stored key, or undefined (none, unreadable, or a production build). */
|
|
27
|
+
export async function readLocalKey(root?: string): Promise<string | undefined> {
|
|
28
|
+
if (isProduction()) return undefined;
|
|
29
|
+
try {
|
|
30
|
+
const key = (await fs.readFile(keyPath(root), "utf8")).trim();
|
|
31
|
+
return key || undefined;
|
|
32
|
+
} catch {
|
|
33
|
+
return undefined;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The key the proxy uses, and where it came from. The environment always wins. */
|
|
38
|
+
export async function currentKey(root?: string): Promise<{ key: string; source: KeySource } | null> {
|
|
39
|
+
const env = process.env.G8_API_KEY?.trim();
|
|
40
|
+
if (env) return { key: env, source: "env" };
|
|
41
|
+
const stored = await readLocalKey(root);
|
|
42
|
+
return stored ? { key: stored, source: "file" } : null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** For `createG8Proxy({ apiKey: resolveApiKey })`. */
|
|
46
|
+
export async function resolveApiKey(): Promise<string | undefined> {
|
|
47
|
+
return (await currentKey())?.key;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** Store a key (validated by the caller): 0700 folder, 0600 file, and a `.gitignore` that ignores the folder. */
|
|
51
|
+
export async function writeLocalKey(key: string, root?: string): Promise<void> {
|
|
52
|
+
const file = keyPath(root);
|
|
53
|
+
const dir = path.dirname(file);
|
|
54
|
+
await fs.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
55
|
+
await fs.writeFile(path.join(dir, ".gitignore"), "# Written by the graph8 Connect screen: never commit this folder.\n*\n", { mode: 0o644 });
|
|
56
|
+
const tmp = `${file}.${process.pid}.tmp`;
|
|
57
|
+
await fs.writeFile(tmp, `${key}\n`, { mode: 0o600 });
|
|
58
|
+
await fs.chmod(tmp, 0o600);
|
|
59
|
+
await fs.rename(tmp, file);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Remove the stored key. Missing is fine. */
|
|
63
|
+
export async function removeLocalKey(root?: string): Promise<void> {
|
|
64
|
+
await fs.rm(keyPath(root), { force: true });
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** `...wxyz`: enough for a person to recognise which key is in use, never enough to use it. */
|
|
68
|
+
export function keyHint(key: string): string {
|
|
69
|
+
return key.length > 8 ? `...${key.slice(-4)}` : "...";
|
|
70
|
+
}
|
|
@@ -14,6 +14,11 @@
|
|
|
14
14
|
* and `allowedPrefixes` (only the areas the UI needs). The opt-outs
|
|
15
15
|
* `dangerouslyAllowUnauthenticated` and `dangerouslyAllowAllPaths` exist for
|
|
16
16
|
* local prototypes only.
|
|
17
|
+
*
|
|
18
|
+
* LOCAL PREVIEW (`localPreview: true`, set in apps made by `graph8-sdk create`):
|
|
19
|
+
* until `authorize` is set, the route answers ONLY `next dev` requests made
|
|
20
|
+
* from this machine (see `isLocalPreviewRequest`). A production build refuses
|
|
21
|
+
* every request (500) until `authorize` is set; the setting then does nothing.
|
|
17
22
|
*/
|
|
18
23
|
|
|
19
24
|
export interface G8ProxyConfig {
|
|
@@ -42,6 +47,19 @@ export interface G8ProxyConfig {
|
|
|
42
47
|
* `targetOrgId` is set.
|
|
43
48
|
*/
|
|
44
49
|
allowTargetOrgHeader?: boolean;
|
|
50
|
+
/**
|
|
51
|
+
* Local preview, for `npm run dev` before your app has sign-in. Only applies while `authorize` is NOT set:
|
|
52
|
+
* the proxy then answers a request only when NODE_ENV is not "production" AND the request comes from this
|
|
53
|
+
* machine (Host localhost, 127.0.0.1 or [::1], same-origin, no forwarding proxy in between). Anything else
|
|
54
|
+
* gets 403; a production build gets 500 until you set `authorize`. Default false.
|
|
55
|
+
*/
|
|
56
|
+
localPreview?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* Where the server's graph8 API key comes from. Default: `process.env.G8_API_KEY`, read on every request.
|
|
59
|
+
* Created apps pass `resolveApiKey` (g8/server/local-key.ts): the environment first, then, in local
|
|
60
|
+
* preview only, the key pasted in the Connect graph8 screen.
|
|
61
|
+
*/
|
|
62
|
+
apiKey?: () => string | undefined | Promise<string | undefined>;
|
|
45
63
|
/** Prototype opt-out: run without `authorize`. Anyone who can reach the route can use your key. */
|
|
46
64
|
dangerouslyAllowUnauthenticated?: boolean;
|
|
47
65
|
/** Prototype opt-out: run without `allowedPrefixes` (every /api/v1 path is reachable). */
|
|
@@ -158,9 +176,81 @@ async function readBodyCapped(request: Request, max: number): Promise<ArrayBuffe
|
|
|
158
176
|
return out;
|
|
159
177
|
}
|
|
160
178
|
|
|
179
|
+
const LOOPBACK_HOSTS = new Set(["localhost", "127.0.0.1", "[::1]", "::1"]);
|
|
180
|
+
const LOOPBACK_ADDRESSES = /^(127\.\d{1,3}\.\d{1,3}\.\d{1,3}|::1|::ffff:127\.\d{1,3}\.\d{1,3}\.\d{1,3}|localhost)$/i;
|
|
181
|
+
|
|
182
|
+
/** `localhost:3000` -> `localhost`, `[::1]:3000` -> `[::1]`. */
|
|
183
|
+
function hostnameOf(host: string): string {
|
|
184
|
+
const h = host.trim().toLowerCase();
|
|
185
|
+
if (h.startsWith("[")) return h.slice(0, h.indexOf("]") + 1);
|
|
186
|
+
return h.replace(/:\d+$/, "");
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function isLoopbackOrigin(value: string): boolean {
|
|
190
|
+
try {
|
|
191
|
+
return LOOPBACK_HOSTS.has(new URL(value).hostname.toLowerCase());
|
|
192
|
+
} catch {
|
|
193
|
+
return false;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* True when a request was made on this machine, by this app's own page (or typed in the address bar):
|
|
199
|
+
* - Host (and X-Forwarded-Host, if any) is localhost, 127.0.0.1 or [::1], which also blocks DNS rebinding;
|
|
200
|
+
* - every X-Forwarded-For hop is a loopback address (tunnels such as ngrok add the real client);
|
|
201
|
+
* - the browser's Sec-Fetch-Site is same-origin or none, and Origin (if any) is a loopback origin,
|
|
202
|
+
* so another website open in the same browser cannot drive the proxy (CSRF).
|
|
203
|
+
* Headers can be forged by a client that reaches the dev server directly, so created apps also bind
|
|
204
|
+
* `next dev` to 127.0.0.1 (package.json). Never rely on this check in production: set `authorize`.
|
|
205
|
+
*/
|
|
206
|
+
export function isLocalPreviewRequest(request: Request): boolean {
|
|
207
|
+
const host = request.headers.get("host") ?? new URL(request.url).host;
|
|
208
|
+
if (!LOOPBACK_HOSTS.has(hostnameOf(host))) return false;
|
|
209
|
+
const fwdHost = request.headers.get("x-forwarded-host");
|
|
210
|
+
if (fwdHost && !fwdHost.split(",").every((h) => LOOPBACK_HOSTS.has(hostnameOf(h)))) return false;
|
|
211
|
+
const fwdFor = request.headers.get("x-forwarded-for");
|
|
212
|
+
if (fwdFor && !fwdFor.split(",").every((a) => LOOPBACK_ADDRESSES.test(a.trim()))) return false;
|
|
213
|
+
const site = request.headers.get("sec-fetch-site");
|
|
214
|
+
if (site && site !== "same-origin" && site !== "none") return false;
|
|
215
|
+
const origin = request.headers.get("origin");
|
|
216
|
+
if (origin && !isLoopbackOrigin(origin)) return false;
|
|
217
|
+
return true;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const isProduction = (): boolean => process.env.NODE_ENV === "production";
|
|
221
|
+
|
|
222
|
+
let previewLogged = false;
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* The local-preview verdict for a request when `authorize` is unset: null to continue, or the refusal.
|
|
226
|
+
* Only called when `localPreview` is on and `authorize` is not set.
|
|
227
|
+
*/
|
|
228
|
+
function localPreviewGate(request: Request): Response | null {
|
|
229
|
+
if (isProduction()) {
|
|
230
|
+
return json(500, {
|
|
231
|
+
error: "g8_proxy_misconfigured",
|
|
232
|
+
message:
|
|
233
|
+
"Local preview only runs under `next dev`. This is a production build, so set `authorize` (check your app's session) in app/api/g8/[...path]/route.ts before going live. `npx @graph8/sdk doctor --production` lists what is left.",
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
if (!isLocalPreviewRequest(request)) {
|
|
237
|
+
return json(403, {
|
|
238
|
+
error: "local_preview_only",
|
|
239
|
+
message: "Local preview only answers requests from this machine (http://localhost). Set `authorize` in app/api/g8/[...path]/route.ts to serve anyone else.",
|
|
240
|
+
});
|
|
241
|
+
}
|
|
242
|
+
if (!previewLogged) {
|
|
243
|
+
previewLogged = true;
|
|
244
|
+
console.warn(
|
|
245
|
+
"[graph8] Local preview: /api/g8 answers requests from this machine only, with no sign-in check. Set `authorize` in app/api/g8/[...path]/route.ts before you deploy.",
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
|
|
161
251
|
/** The first reason this config is unsafe to serve, or null. Checked on every request. */
|
|
162
252
|
function configProblem(config: G8ProxyConfig): string | null {
|
|
163
|
-
if (!config.authorize && !config.dangerouslyAllowUnauthenticated) {
|
|
253
|
+
if (!config.authorize && !config.dangerouslyAllowUnauthenticated && !config.localPreview) {
|
|
164
254
|
return "Set `authorize` (check your app's session) in app/api/g8/[...path]/route.ts. The proxy refuses to run without it.";
|
|
165
255
|
}
|
|
166
256
|
if (!config.allowedPrefixes && !config.dangerouslyAllowAllPaths) {
|
|
@@ -177,11 +267,21 @@ export function createG8Proxy(config: G8ProxyConfig = {}) {
|
|
|
177
267
|
async function handle(request: Request, context: RouteContext): Promise<Response> {
|
|
178
268
|
const problem = configProblem(config);
|
|
179
269
|
if (problem) return json(500, { error: "g8_proxy_misconfigured", message: problem });
|
|
270
|
+
const preview = Boolean(config.localPreview && !config.authorize && !config.dangerouslyAllowUnauthenticated);
|
|
271
|
+
if (preview) {
|
|
272
|
+
const refused = localPreviewGate(request);
|
|
273
|
+
if (refused) return refused;
|
|
274
|
+
}
|
|
180
275
|
const denied = await config.authorize?.(request);
|
|
181
276
|
if (denied) return denied;
|
|
182
277
|
|
|
183
|
-
const apiKey = process.env.G8_API_KEY;
|
|
184
|
-
if (!apiKey)
|
|
278
|
+
const apiKey = config.apiKey ? await config.apiKey() : process.env.G8_API_KEY;
|
|
279
|
+
if (!apiKey) {
|
|
280
|
+
const message = preview
|
|
281
|
+
? "No graph8 API key yet: open this app in the browser and paste one in the Connect graph8 screen, or set G8_API_KEY in .env.local."
|
|
282
|
+
: "G8_API_KEY is not set on the server.";
|
|
283
|
+
return json(500, { error: "g8_proxy_misconfigured", message });
|
|
284
|
+
}
|
|
185
285
|
|
|
186
286
|
const params = await (context.params as Promise<{ path?: string[] }> | { path?: string[] });
|
|
187
287
|
const path = normalizePath(params?.path);
|
|
@@ -4,18 +4,30 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Next.js only allows HTTP-method names and a few config exports from a route
|
|
6
6
|
* file, so the proxy configuration is a plain constant here, not an export.
|
|
7
|
-
* Edit it.
|
|
8
|
-
*
|
|
7
|
+
* Edit it. Before you deploy, set `authorize`: a production build refuses every
|
|
8
|
+
* request (500) until it is set, because this route carries the full-power API key.
|
|
9
|
+
* `npx @graph8/sdk doctor --production` checks this file for you.
|
|
9
10
|
*/
|
|
10
11
|
|
|
11
12
|
import { createG8Proxy } from "@/g8/server/proxy";
|
|
13
|
+
import { resolveApiKey } from "@/g8/server/local-key";
|
|
12
14
|
|
|
13
|
-
// 1.
|
|
15
|
+
// 1. The API areas this app's UI may call, relative to /api/v1. REQUIRED.
|
|
16
|
+
// The lines between the graph8:allowed-prefixes markers are GENERATED from the operations the installed blocks
|
|
17
|
+
// declare, and rewritten by `npx @graph8/sdk add <block>` and `npx @graph8/sdk doctor --fix-prefixes`. To own the
|
|
18
|
+
// list yourself, delete both marker lines; the CLI then never touches it.
|
|
19
|
+
// graph8:allowed-prefixes:start
|
|
14
20
|
const allowedPrefixes: string[] | undefined = undefined; // e.g. ["contacts", "companies", "lists"]
|
|
21
|
+
// graph8:allowed-prefixes:end
|
|
15
22
|
|
|
16
23
|
export const { GET, POST, PUT, PATCH, DELETE } = createG8Proxy({
|
|
17
24
|
allowedPrefixes,
|
|
18
|
-
// 2.
|
|
25
|
+
// 2. Local preview: until `authorize` is set, answer only `npm run dev` requests from this machine
|
|
26
|
+
// (http://localhost). Ignored once `authorize` is set; a production build never runs without `authorize`.
|
|
27
|
+
localPreview: true,
|
|
28
|
+
// The key: G8_API_KEY from the environment, else (local preview only) the one pasted in the Connect graph8 screen.
|
|
29
|
+
apiKey: resolveApiKey,
|
|
30
|
+
// 3. Check YOUR session and return a Response (401/403) to refuse. REQUIRED before you deploy:
|
|
19
31
|
// authorize: async (request) => { if (!(await getSession(request))) return new Response("Unauthorized", { status: 401 }); },
|
|
20
32
|
//
|
|
21
33
|
// Agency keys only: pick the client org from the session, never from the browser:
|
package/blocks/agents/README.md
CHANGED
|
@@ -4,6 +4,20 @@ An agent is a persona (how it talks), a voice, knowledge (documents and company
|
|
|
4
4
|
(single-purpose actions). This block gives you the screens to build one, prove it works before anyone talks
|
|
5
5
|
to it, and follow the runs agents do for you.
|
|
6
6
|
|
|
7
|
+
## Screens
|
|
8
|
+
|
|
9
|
+
`AgentsScreen` (`components/agents-screen.tsx`) is the finished agents workspace; mount it as the page.
|
|
10
|
+
|
|
11
|
+
- **Agents tab**: stat strip (agents, active, inactive), search, status filter, the list, **New agent** (form sheet; the
|
|
12
|
+
agent starts inactive), activate / deactivate. Opening an agent (deep link `?id=`) shows a detail panel with
|
|
13
|
+
**Configure** (persona, voice, knowledge, skills, delete), **Test** (free dry runs, saved cases, the activation gate,
|
|
14
|
+
run a skill live) and **Runs** (this agent's runs).
|
|
15
|
+
- **Runs tab**: every run as a list; opening one (`?run=`) shows `AgentRunDetail` (status, step, credits, times, its approvals to approve or reject, cancel). Start a run here. `&view=full` opens any record as a full page.
|
|
16
|
+
- Uploading documents, running a skill, starting or cancelling a run, approving and deleting an agent are confirmed
|
|
17
|
+
with `ConfirmSpendDialog` inside the parts. Needs `voice:read|write|delete`, `knowledge:read`, `workflows:*`.
|
|
18
|
+
- Slots: `renderRowActions`, `renderDetailActions`, `extraDetailTabs`, `toolbarActions`. `AgentBuilder` gained
|
|
19
|
+
`hideList`; `AgentRuns` gained `selectedId`, `onSelectRun`, `hideDetail`, `refreshKey`; new `AgentRunDetail`.
|
|
20
|
+
|
|
7
21
|
Install: `npx @graph8/sdk add agents` (installs `foundation` first).
|
|
8
22
|
|
|
9
23
|
## What you get
|
package/blocks/agents/block.json
CHANGED
|
@@ -47,6 +47,37 @@
|
|
|
47
47
|
"upload_agent_documents_voice_agents__agent_id__documents_upload_post"
|
|
48
48
|
],
|
|
49
49
|
"components": [
|
|
50
|
+
{
|
|
51
|
+
"name": "AgentsScreen",
|
|
52
|
+
"file": "components/agents-screen.tsx",
|
|
53
|
+
"purpose": "Finished agents workspace: stats, search, list, create sheet, and a detail panel with configure, test and runs, plus a workspace-wide Runs tab.",
|
|
54
|
+
"operations": [
|
|
55
|
+
"create_voice_agent_voice_agents_post",
|
|
56
|
+
"get_voice_agent_voice_agents__agent_id__get",
|
|
57
|
+
"list_voice_agents_voice_agents_get",
|
|
58
|
+
"update_voice_agent_voice_agents__agent_id__put"
|
|
59
|
+
],
|
|
60
|
+
"tiers": [
|
|
61
|
+
"read",
|
|
62
|
+
"write"
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"name": "AgentRunDetail",
|
|
67
|
+
"file": "components/agent-run-detail.tsx",
|
|
68
|
+
"purpose": "One agent run in full: status, step, credits, times, its pending approvals and cancel.",
|
|
69
|
+
"operations": [
|
|
70
|
+
"get_agent_run_agent_runs__run_id__get",
|
|
71
|
+
"list_agent_approvals_agent_approvals_get",
|
|
72
|
+
"decide_agent_approval_agent_approvals__approval_id__decide_post",
|
|
73
|
+
"cancel_agent_run_agent_runs__run_id__cancel_post"
|
|
74
|
+
],
|
|
75
|
+
"tiers": [
|
|
76
|
+
"read",
|
|
77
|
+
"write",
|
|
78
|
+
"destructive"
|
|
79
|
+
]
|
|
80
|
+
},
|
|
50
81
|
{
|
|
51
82
|
"name": "AgentBuilder",
|
|
52
83
|
"file": "components/agent-builder.tsx",
|
|
@@ -218,6 +249,7 @@
|
|
|
218
249
|
}
|
|
219
250
|
],
|
|
220
251
|
"peerDependencies": {
|
|
221
|
-
"react": ">=18"
|
|
252
|
+
"react": ">=18",
|
|
253
|
+
"lucide-react": ">=0.400.0"
|
|
222
254
|
}
|
|
223
255
|
}
|
|
@@ -37,6 +37,8 @@ export interface AgentBuilderProps {
|
|
|
37
37
|
onSelect?: (agentId: string | null) => void;
|
|
38
38
|
/** Called after a create, update or delete so the host can refresh its own lists. */
|
|
39
39
|
onChanged?: () => void;
|
|
40
|
+
/** Hide the built-in agent list and "New agent" button when the host (for example AgentsScreen) provides its own. */
|
|
41
|
+
hideList?: boolean;
|
|
40
42
|
className?: string;
|
|
41
43
|
}
|
|
42
44
|
|
|
@@ -60,7 +62,7 @@ function Dial({ label, value, onChange, low, high }: { label: string; value: num
|
|
|
60
62
|
);
|
|
61
63
|
}
|
|
62
64
|
|
|
63
|
-
export function AgentBuilder({ agentId: controlled, onSelect, onChanged, className }: AgentBuilderProps) {
|
|
65
|
+
export function AgentBuilder({ agentId: controlled, onSelect, onChanged, hideList, className }: AgentBuilderProps) {
|
|
64
66
|
const [inner, setInner] = useState<string | null>(null);
|
|
65
67
|
const selected = controlled !== undefined ? controlled : inner;
|
|
66
68
|
const [creating, setCreating] = useState(false);
|
|
@@ -70,7 +72,7 @@ export function AgentBuilder({ agentId: controlled, onSelect, onChanged, classNa
|
|
|
70
72
|
const [deleting, setDeleting] = useState(false);
|
|
71
73
|
|
|
72
74
|
const choose = (id: string | null) => { setInner(id); setCreating(false); onSelect?.(id); };
|
|
73
|
-
const agents = useG8Query((signal) => g8Get("/voice/agents", { search: search.trim() || undefined, page_size: 50 }, { signal }), [search]);
|
|
75
|
+
const agents = useG8Query((signal) => g8Get("/voice/agents", { search: search.trim() || undefined, page_size: 50 }, { signal }), [search], { enabled: !hideList });
|
|
74
76
|
const detail = useG8Query((signal) => g8Get(`/voice/agents/${selected}`, undefined, { signal }), [selected], { enabled: Boolean(selected) && !creating });
|
|
75
77
|
const voices = useG8Query((signal) => g8Get("/voice/voice-library/presets", { limit: 100 }, { signal }), []);
|
|
76
78
|
|
|
@@ -87,7 +89,7 @@ export function AgentBuilder({ agentId: controlled, onSelect, onChanged, classNa
|
|
|
87
89
|
{
|
|
88
90
|
onSuccess: (res) => {
|
|
89
91
|
const id = bodyOf<{ agent?: { agent_id?: string } }>(res).agent?.agent_id;
|
|
90
|
-
void agents.refetch();
|
|
92
|
+
if (!hideList) void agents.refetch();
|
|
91
93
|
// Keep the new id on the draft at once: a second click (or a retry after a later step fails) must update, never create a twin.
|
|
92
94
|
if (!draft?.agent_id && id) { setDraft((d) => d && { ...d, agent_id: id }); choose(id); }
|
|
93
95
|
else void detail.refetch();
|
|
@@ -96,17 +98,19 @@ export function AgentBuilder({ agentId: controlled, onSelect, onChanged, classNa
|
|
|
96
98
|
},
|
|
97
99
|
);
|
|
98
100
|
const remove = useG8Mutation((id: string) => g8Delete(`/voice/agents/${id}`), {
|
|
99
|
-
onSuccess: () => { setDeleting(false); setDraft(null); choose(null); void agents.refetch(); onChanged?.(); },
|
|
101
|
+
onSuccess: () => { setDeleting(false); setDraft(null); choose(null); if (!hideList) void agents.refetch(); onChanged?.(); },
|
|
100
102
|
});
|
|
101
103
|
|
|
102
104
|
const rows = listOf<AgentSummary>(agents.data, "agents");
|
|
103
105
|
const presets = listOf<{ voice_id?: string; id?: string; name?: string; label?: string }>(voices.data, "items");
|
|
104
106
|
const setPersona = (patch: Partial<AgentDetail["persona"]>) => setDraft((d) => d && { ...d, persona: { ...d.persona, ...patch } });
|
|
107
|
+
// The hook keeps the previous agent's data while the next one loads: until it arrives the old form must not be shown (or saved) as the new agent.
|
|
108
|
+
const stale = Boolean(draft?.agent_id && selected && draft.agent_id !== selected && !creating);
|
|
105
109
|
const valid = Boolean(draft?.persona.agent_name.trim() && draft?.persona.persona.trim() && draft?.persona.description.trim() && draft?.role.trim());
|
|
106
110
|
|
|
107
111
|
return (
|
|
108
|
-
<div className={`grid gap-4 md:grid-cols-[16rem_1fr] ${className ?? ""}`}>
|
|
109
|
-
<aside className="flex flex-col gap-2">
|
|
112
|
+
<div className={`grid gap-4 ${hideList ? "" : "md:grid-cols-[16rem_1fr]"} ${className ?? ""}`}>
|
|
113
|
+
{hideList ? null : <aside className="flex flex-col gap-2">
|
|
110
114
|
<div className="flex gap-2">
|
|
111
115
|
<Input aria-label="Search agents" placeholder="Search agents" value={search} onChange={(e) => setSearch(e.target.value)} />
|
|
112
116
|
<Button size="sm" onClick={() => { setCreating(true); setDraft(blankDraft()); setSection("persona"); onSelect?.(null); }}>New agent</Button>
|
|
@@ -127,12 +131,13 @@ export function AgentBuilder({ agentId: controlled, onSelect, onChanged, classNa
|
|
|
127
131
|
{!agents.loading && !agents.error && rows.length === 0 ? (
|
|
128
132
|
<EmptyState title="No agents yet" description="An agent has a persona, a voice, knowledge and skills." />
|
|
129
133
|
) : null}
|
|
130
|
-
</aside>
|
|
134
|
+
</aside>}
|
|
131
135
|
|
|
132
136
|
<section className="flex min-w-0 flex-col gap-3">
|
|
133
137
|
{!draft && !creating ? <EmptyState title="Pick an agent" description="Choose one on the left or create a new agent." /> : null}
|
|
134
138
|
{detail.error ? <ApiError error={detail.error} title="Could not load the agent" onRetry={() => void detail.refetch()} /> : null}
|
|
135
|
-
{
|
|
139
|
+
{stale && !detail.error ? <Skeleton className="h-64 w-full" aria-label="Loading agent" /> : null}
|
|
140
|
+
{draft && !stale ? (
|
|
136
141
|
<>
|
|
137
142
|
<div role="tablist" className="flex gap-1 border-b">
|
|
138
143
|
{SECTIONS.map((s) => (
|
|
@@ -25,6 +25,7 @@ import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
|
25
25
|
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
26
26
|
import { g8Delete, g8Get, g8Post, g8Put } from "@/g8/lib/g8-client";
|
|
27
27
|
import { listOf } from "../lib/agent-types";
|
|
28
|
+
import { plural } from "@/g8/lib/plural";
|
|
28
29
|
|
|
29
30
|
export interface AgentKnowledgePanelProps {
|
|
30
31
|
agentId: string;
|
|
@@ -132,7 +133,7 @@ export function AgentKnowledgePanel({ agentId, useCompanyKnowledge, onToggleComp
|
|
|
132
133
|
|
|
133
134
|
{upload.error ? <ApiError error={upload.error} title="Could not upload" /> : null}
|
|
134
135
|
{remove.error ? <ApiError error={remove.error} title="Could not remove the document" /> : null}
|
|
135
|
-
<ConfirmSpendDialog open={pending != null} tier="billable" title={`Index ${pending?.length ?? 0
|
|
136
|
+
<ConfirmSpendDialog open={pending != null} tier="billable" title={`Index ${plural(pending?.length ?? 0, "document")}`}
|
|
136
137
|
description="The text is extracted and embedded with a paid model so the agent can search it."
|
|
137
138
|
estimatedCredits={(pending?.length ?? 0) * creditsPerFile} confirmLabel="Upload" confirming={upload.loading}
|
|
138
139
|
onConfirm={() => pending && void upload.mutate(pending)} onCancel={() => setPending(null)} />
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* AgentRunDetail: one agent run in full. Title, status, agent, current step, next action, credits against the budget,
|
|
5
|
+
* created and completed times, the approvals this run is waiting on (approve or reject), and Cancel while it is live.
|
|
6
|
+
* The run is polled until it is terminal (the API has no streams).
|
|
7
|
+
*
|
|
8
|
+
* SDK operations, in order:
|
|
9
|
+
* 1. getAgentRun GET /agent/runs/{run_id} (read) polled
|
|
10
|
+
* 2. listAgentApprovals GET /agent/approvals (read) this run's pending ones
|
|
11
|
+
* 3. decideAgentApproval POST /agent/approvals/{approval_id}/decide { decision } (write; approving lets the run act: ConfirmSpendDialog, external)
|
|
12
|
+
* 4. cancelAgentRun POST /agent/runs/{run_id}/cancel (destructive, ConfirmSpendDialog)
|
|
13
|
+
* Approvals that gate a Work coordination action cannot be decided with an API key: the error is shown as is.
|
|
14
|
+
*/
|
|
15
|
+
import { useEffect, useState } from "react";
|
|
16
|
+
import { Button, Skeleton } from "@graph8/sdk/ui";
|
|
17
|
+
import { ApiError } from "@/g8/components/api-error";
|
|
18
|
+
import { ConfirmSpendDialog } from "@/g8/components/confirm-spend-dialog";
|
|
19
|
+
import { KeyValueList } from "@/g8/components/screen/key-value-list";
|
|
20
|
+
import { StatusPill, toneForStatus } from "@/g8/components/screen/status-pill";
|
|
21
|
+
import { useG8Mutation } from "@/g8/hooks/use-g8-mutation";
|
|
22
|
+
import { useG8Query } from "@/g8/hooks/use-g8-query";
|
|
23
|
+
import { useJobPoll } from "@/g8/hooks/use-job-poll";
|
|
24
|
+
import { g8Get, g8Post } from "@/g8/lib/g8-client";
|
|
25
|
+
import { bodyOf, listOf } from "../lib/agent-types";
|
|
26
|
+
import type { AgentRun } from "../lib/agent-types";
|
|
27
|
+
import { isTerminalRun } from "./agent-runs";
|
|
28
|
+
|
|
29
|
+
export interface AgentRunDetailProps {
|
|
30
|
+
runId: string;
|
|
31
|
+
/** Poll interval in ms. Default 3000. */
|
|
32
|
+
pollMs?: number;
|
|
33
|
+
/** Called after a cancel or a decision so the host can refresh its run list. */
|
|
34
|
+
onChanged?: () => void;
|
|
35
|
+
className?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface Approval { id: string; summary: string; risk_level: string; work_item_id: string; decision?: string | null; is_expired?: boolean }
|
|
39
|
+
|
|
40
|
+
const when = (v?: string | null): string => (v ? new Date(v).toLocaleString() : "");
|
|
41
|
+
|
|
42
|
+
export function AgentRunDetail({ runId, pollMs = 3000, onChanged, className }: AgentRunDetailProps) {
|
|
43
|
+
const [cancelling, setCancelling] = useState(false);
|
|
44
|
+
const [approving, setApproving] = useState<Approval | null>(null);
|
|
45
|
+
const [deciding, setDeciding] = useState<string | null>(null);
|
|
46
|
+
const poll = useJobPoll<AgentRun>((signal) => g8Get<AgentRun>(`/agent/runs/${encodeURIComponent(runId)}`, undefined, { signal }).then((r) => bodyOf<AgentRun>(r)), {
|
|
47
|
+
isTerminal: isTerminalRun, intervalMs: pollMs, maxIntervalMs: pollMs * 4, onDone: () => onChanged?.(),
|
|
48
|
+
});
|
|
49
|
+
const approvals = useG8Query((signal) => g8Get("/agent/approvals", undefined, { signal }), [runId]);
|
|
50
|
+
useEffect(() => { poll.start(); setCancelling(false); setApproving(null); }, [runId]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
51
|
+
useEffect(() => { if (poll.attempts > 0) void approvals.refetch(); }, [poll.attempts]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
52
|
+
|
|
53
|
+
const cancel = useG8Mutation(() => g8Post(`/agent/runs/${encodeURIComponent(runId)}/cancel`, {}), { onSuccess: () => { setCancelling(false); poll.start(); onChanged?.(); }, onError: () => setCancelling(false) });
|
|
54
|
+
const decide = useG8Mutation(async (v: { id: string; decision: "approve" | "reject" }) => {
|
|
55
|
+
setDeciding(v.id);
|
|
56
|
+
try { return await g8Post(`/agent/approvals/${encodeURIComponent(v.id)}/decide`, { decision: v.decision }); } finally { setDeciding(null); }
|
|
57
|
+
}, { onSuccess: () => { setApproving(null); void approvals.refetch(); onChanged?.(); }, onError: () => setApproving(null) });
|
|
58
|
+
|
|
59
|
+
const run = poll.status?.work_item_id === runId ? poll.status : undefined;
|
|
60
|
+
const pending = listOf<Approval>(approvals.data).filter((a) => a.work_item_id === runId && !a.decision && !a.is_expired);
|
|
61
|
+
if (!run) return poll.error ? <ApiError error={poll.error} title="Could not load the run" onRetry={() => poll.start()} /> : <Skeleton className="h-40 w-full" aria-label="Loading run" />;
|
|
62
|
+
|
|
63
|
+
return (
|
|
64
|
+
<div className={`space-y-4 ${className ?? ""}`} data-testid="run-detail">
|
|
65
|
+
<div className="flex flex-wrap items-center gap-2">
|
|
66
|
+
<StatusPill tone={toneForStatus(run.status)} dot>{run.status}</StatusPill>
|
|
67
|
+
{poll.polling ? <span className="text-xs text-muted-foreground">updating</span> : null}
|
|
68
|
+
{!isTerminalRun(run) ? <Button className="ml-auto" size="sm" variant="outline" onClick={() => setCancelling(true)}>Cancel run</Button> : null}
|
|
69
|
+
</div>
|
|
70
|
+
<KeyValueList columns={2} items={[
|
|
71
|
+
{ label: "Title", value: run.title, wide: true },
|
|
72
|
+
{ label: "Agent", value: run.agent_id, mono: true },
|
|
73
|
+
{ label: "Run id", value: run.work_item_id, mono: true },
|
|
74
|
+
{ label: "Current step", value: run.current_step },
|
|
75
|
+
{ label: "Next action", value: run.next_action },
|
|
76
|
+
{ label: "Credits", value: `${run.credits_spent ?? 0}${run.credit_budget ? ` of ${run.credit_budget}` : ""}` },
|
|
77
|
+
{ label: "Started", value: when(run.created_at) },
|
|
78
|
+
{ label: "Finished", value: when(run.completed_at) },
|
|
79
|
+
]} />
|
|
80
|
+
{pending.length > 0 ? (
|
|
81
|
+
<section className="space-y-2 rounded-md border p-3" aria-label="Approvals" data-testid="run-approvals">
|
|
82
|
+
<h4 className="text-sm font-semibold">Waiting for your decision ({pending.length})</h4>
|
|
83
|
+
{pending.map((a) => (
|
|
84
|
+
<div key={a.id} className="flex flex-wrap items-center justify-between gap-2 text-sm">
|
|
85
|
+
<span>{a.summary}<span className="ml-2 text-xs text-muted-foreground">risk: {a.risk_level}</span></span>
|
|
86
|
+
<span className="flex gap-2">
|
|
87
|
+
<Button size="sm" variant="outline" loading={deciding === a.id} disabled={deciding != null && deciding !== a.id} onClick={() => void decide.mutate({ id: a.id, decision: "reject" })}>Reject</Button>
|
|
88
|
+
<Button size="sm" disabled={deciding != null} onClick={() => setApproving(a)}>Approve</Button>
|
|
89
|
+
</span>
|
|
90
|
+
</div>
|
|
91
|
+
))}
|
|
92
|
+
</section>
|
|
93
|
+
) : null}
|
|
94
|
+
{decide.error ? <ApiError error={decide.error} title="Could not record the decision" /> : null}
|
|
95
|
+
{cancel.error ? <ApiError error={cancel.error} title="Could not cancel the run" /> : null}
|
|
96
|
+
<ConfirmSpendDialog open={cancelling} tier="destructive" title={`Cancel "${run.title}"`} description="Work stops where it is. Steps already taken are not undone."
|
|
97
|
+
confirmLabel="Cancel run" confirming={cancel.loading} onConfirm={() => void cancel.mutate(undefined)} onCancel={() => setCancelling(false)} />
|
|
98
|
+
<ConfirmSpendDialog open={approving != null} tier="external" title="Approve this action" description={approving?.summary}
|
|
99
|
+
confirmLabel="Approve" confirming={decide.loading} onConfirm={() => approving && void decide.mutate({ id: approving.id, decision: "approve" })} onCancel={() => setApproving(null)} />
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -36,6 +36,13 @@ export interface AgentRunsProps {
|
|
|
36
36
|
pageSize?: number;
|
|
37
37
|
/** Poll interval for the selected run, in ms. Default 3000. */
|
|
38
38
|
pollMs?: number;
|
|
39
|
+
/** Controlled selection (a run id). With it, the host owns which run is open (for example from the URL). */
|
|
40
|
+
selectedId?: string | null;
|
|
41
|
+
onSelectRun?: (runId: string | null) => void;
|
|
42
|
+
/** Hide the built-in detail box when the host shows the run elsewhere (see AgentRunDetail). */
|
|
43
|
+
hideDetail?: boolean;
|
|
44
|
+
/** Change this number to reload the list (for example after AgentRunDetail cancelled a run). */
|
|
45
|
+
refreshKey?: number;
|
|
39
46
|
className?: string;
|
|
40
47
|
}
|
|
41
48
|
|
|
@@ -46,28 +53,30 @@ const TERMINAL = new Set(["completed", "succeeded", "failed", "cancelled", "canc
|
|
|
46
53
|
export const isTerminalRun = (r: Pick<AgentRun, "status"> | undefined): boolean => TERMINAL.has(String(r?.status ?? "").toLowerCase());
|
|
47
54
|
const STATUSES = ["running", "queued", "completed", "failed", "cancelled"];
|
|
48
55
|
|
|
49
|
-
export function AgentRuns({ agentId, pageSize = 20, pollMs = 3000, className }: AgentRunsProps) {
|
|
56
|
+
export function AgentRuns({ agentId, pageSize = 20, pollMs = 3000, selectedId, onSelectRun, hideDetail, refreshKey, className }: AgentRunsProps) {
|
|
50
57
|
const [status, setStatus] = useState("");
|
|
51
58
|
const [limit, setLimit] = useState(pageSize);
|
|
52
|
-
const [
|
|
59
|
+
const [innerSel, setInnerSel] = useState<string | null>(null);
|
|
60
|
+
const selected = selectedId !== undefined ? selectedId : innerSel;
|
|
61
|
+
const setSelected = (id: string | null) => { setInnerSel(id); onSelectRun?.(id); };
|
|
53
62
|
const [form, setForm] = useState<{ agent_id: string; title: string; goal: string } | null>(null);
|
|
54
63
|
const [confirmStart, setConfirmStart] = useState(false);
|
|
55
64
|
const [cancelling, setCancelling] = useState<AgentRun | null>(null);
|
|
56
65
|
const [approving, setApproving] = useState<Approval | null>(null);
|
|
57
66
|
const [deciding, setDeciding] = useState<string | null>(null);
|
|
58
67
|
|
|
59
|
-
const runs = useG8Query((signal) => g8Get("/agent/runs", { statuses: status || undefined, agent_id: agentId, limit }, { signal }), [status, agentId, limit]);
|
|
68
|
+
const runs = useG8Query((signal) => g8Get("/agent/runs", { statuses: status || undefined, agent_id: agentId, limit }, { signal }), [status, agentId, limit, refreshKey]);
|
|
60
69
|
const approvals = useG8Query((signal) => g8Get("/agent/approvals", undefined, { signal }), []);
|
|
61
70
|
const specialists = useG8Query((signal) => g8Get("/agent/specialists", undefined, { signal }), [], { enabled: form != null });
|
|
62
71
|
const detail = useJobPoll<AgentRun>((signal) => g8Get<AgentRun>(`/agent/runs/${selected}`, undefined, { signal }).then((r) => bodyOf<AgentRun>(r)), {
|
|
63
|
-
enabled: selected != null, isTerminal: isTerminalRun, intervalMs: pollMs, maxIntervalMs: pollMs * 4, onDone: () => void runs.refetch(),
|
|
72
|
+
enabled: selected != null && !hideDetail, isTerminal: isTerminalRun, intervalMs: pollMs, maxIntervalMs: pollMs * 4, onDone: () => void runs.refetch(),
|
|
64
73
|
});
|
|
65
74
|
|
|
66
75
|
// Switching runs keeps `enabled` true, so restart the poll explicitly.
|
|
67
|
-
useEffect(() => { if (selected) detail.start(); }, [selected]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
76
|
+
useEffect(() => { if (selected && !hideDetail) detail.start(); }, [selected]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
68
77
|
|
|
69
78
|
// A run that stops on an approval keeps polling: refresh the approvals on every poll that lands.
|
|
70
|
-
useEffect(() => { if (selected && detail.attempts > 0) void approvals.refetch(); }, [detail.attempts]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
79
|
+
useEffect(() => { if (selected && !hideDetail && detail.attempts > 0) void approvals.refetch(); }, [detail.attempts]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
71
80
|
|
|
72
81
|
const start = useG8Mutation(() => g8Post<AgentRun>("/agent/runs", { agent_id: form?.agent_id ?? agentId, title: form?.title.trim(), goal: form?.goal.trim() }), {
|
|
73
82
|
onSuccess: (res) => { setConfirmStart(false); setForm(null); setSelected(bodyOf<AgentRun>(res).work_item_id ?? null); void runs.refetch(); },
|
|
@@ -147,7 +156,7 @@ export function AgentRuns({ agentId, pageSize = 20, pollMs = 3000, className }:
|
|
|
147
156
|
{!runs.loading && !runs.error && rows.length === 0 ? <EmptyState title="No runs yet" description="A run is one goal worked on by an agent, step by step, with approvals where it matters." /> : null}
|
|
148
157
|
{rows.length < total ? <Button variant="outline" size="sm" onClick={() => setLimit(limit + pageSize)}>Load more</Button> : null}
|
|
149
158
|
|
|
150
|
-
{selected ? (
|
|
159
|
+
{selected && !hideDetail ? (
|
|
151
160
|
<div className="rounded-md border p-3" data-testid="run-detail">
|
|
152
161
|
{current ? (
|
|
153
162
|
<>
|