From 6995a20968bf295c6ca55c8c58506bb5c592045d Mon Sep 17 00:00:00 2001 From: Gabe Hamilton Date: Fri, 15 Mar 2024 16:17:38 -0600 Subject: [PATCH] Entity details (#707) * feat: entity details template * fix: backwards compatibility for old agent nexus * feat: agent indexer supports entities --- indexers/agents/agents.js | 100 +++++--- indexers/agents/agents.sql | 25 +- src/AI/Agent/AgentCreate.jsx | 18 +- src/AI/Nexus.jsx | 10 +- src/Entities/QueryApi/Client.jsx | 19 ++ src/Entities/Template/EntityCreate.jsx | 11 +- src/Entities/Template/EntityDetails.jsx | 145 +++++++++++ src/Entities/Template/EntityList.jsx | 24 +- src/Entities/Template/EntitySummary.jsx | 237 ++++++++++++++++++ src/Entities/Template/GenericEntityConfig.jsx | 196 +++++++++++++++ src/Entities/Template/GenericSchema.jsx | 58 +++++ 11 files changed, 787 insertions(+), 56 deletions(-) create mode 100644 src/Entities/Template/EntityDetails.jsx create mode 100644 src/Entities/Template/EntitySummary.jsx create mode 100644 src/Entities/Template/GenericEntityConfig.jsx create mode 100644 src/Entities/Template/GenericSchema.jsx diff --git a/indexers/agents/agents.js b/indexers/agents/agents.js index 258d97ea..2b224c0b 100644 --- a/indexers/agents/agents.js +++ b/indexers/agents/agents.js @@ -82,39 +82,81 @@ async function getBlock(block: Block) { }); }; +// Agi Guild + const agiWrites = getSocialOperations(block, "agiguild"); + + agiWrites.map(async ({ accountId, data }) => { + try { + const entityTypes = Object.keys(data); + entityTypes.map(async (entityType)=> { + const entities = Object.keys(data[entityType]); + entities.map(async (name) => { + const entityProps = data[entityType][name]; + const {displayName, logoUrl, ...entityAttributes} = entityProps; + const entity = { + entity_type: entityType, + account_id: accountId, + name: name, + display_name: displayName, + logo_url: logoUrl, + attributes: entityAttributes, + }; + await context.db.Entities.upsert( + entity, + ["entity_type", "account_id", "name"], + [ + "display_name", + "logo_url", + "attributes", + ] + ); + + console.log(`${entityType} from ${accountId} has been added to the database`); + }); + }); + } catch (e) { + console.log(`Failed to store agent from ${accountId} to the database`, e); + } + }); + +// Legacy const agentWrites = getSocialOperations(block, "agent"); agentWrites.map(async ({ accountId, data }) => { try { - const agent = { - name: data.name, - account_id: accountId, - display_name: data.displayName, - preferred_provider: data.preferredProvider, - preferred_model: data.preferredModel, - prompt: data.prompt, - variables: data.variables, - component: data.component, - logo_url: data.logoUrl, - tools: data.tools, - properties: data.properties, - }; - await context.db.Agents.upsert( - agent, - ["account_id", "name"], - [ - "display_name", - "preferred_provider", - "preferred_model", - "prompt", - "variables", - "component", - "logo_url", - "tools", - "properties", - ] - ); + const names = Object.keys(data); + names.map( async (name) => { + const agentProps = data[name]; + const agent = { + name: name, + account_id: accountId, + display_name: agentProps.displayName, + preferred_provider: agentProps.preferredProvider, + preferred_model: agentProps.preferredModel, + prompt: agentProps.prompt, + variables: agentProps.variables, + component: agentProps.component, + logo_url: agentProps.logoUrl, + tools: agentProps.tools, + properties: agentProps.properties, + }; + await context.db.Agents.upsert( + agent, + ["account_id", "name"], + [ + "display_name", + "preferred_provider", + "preferred_model", + "prompt", + "variables", + "component", + "logo_url", + "tools", + "properties", + ] + ); - console.log(`Agent from ${accountId} has been added to the database`); + console.log(`Agent from ${accountId} has been added to the database`); + }); } catch (e) { console.log(`Failed to store agent from ${accountId} to the database`, e); } diff --git a/indexers/agents/agents.sql b/indexers/agents/agents.sql index 14cdcea1..e0616cb1 100644 --- a/indexers/agents/agents.sql +++ b/indexers/agents/agents.sql @@ -17,4 +17,27 @@ CREATE TABLE PRIMARY KEY ("id") ); -ALTER TABLE "agents" ADD CONSTRAINT "unique_name" UNIQUE ("account_id", "name"); \ No newline at end of file +ALTER TABLE "agents" ADD CONSTRAINT "unique_name" UNIQUE ("account_id", "name"); + +-- commented lines are features that are not supported by context.db but are in the table +-- that was created in the db +CREATE TABLE "entities" +( + "id" SERIAL NOT NULL, + "entity_type" TEXT NOT NULL, + "account_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "display_name" TEXT, + "logo_url" TEXT, + "attributes" JSONB, + "stars" integer, -- default 0 + -- "created_at" timestamp with time zone default now(), + -- "updated_at" timestamp with time zone default now(), + PRIMARY KEY ("entity_type", "account_id", "name") +); + +-- CREATE TRIGGER set_dataplatform_near_agents_entities_updated_at +-- BEFORE UPDATE +-- ON dataplatform_near_agents.entities +-- FOR EACH ROW +-- EXECUTE FUNCTION dataplatform_near_agents.set_current_timestamp_updated_at(); \ No newline at end of file diff --git a/src/AI/Agent/AgentCreate.jsx b/src/AI/Agent/AgentCreate.jsx index 8c6276f5..fed5043b 100644 --- a/src/AI/Agent/AgentCreate.jsx +++ b/src/AI/Agent/AgentCreate.jsx @@ -8,6 +8,7 @@ const onSubmitFunction = onSubmit ?? onSubmitDefault; const AgentInputsPartialSchema = { name: { + type: "string", inputProps: { min: 2, max: 80, @@ -19,6 +20,7 @@ const AgentInputsPartialSchema = { order: 1, }, displayName: { + type: "string", inputProps: { min: 2, max: 255, @@ -29,6 +31,7 @@ const AgentInputsPartialSchema = { order: 2, }, prompt: { + type: "string", inputProps: { min: 2, max: 8192, @@ -70,6 +73,7 @@ const AgentInputsPartialSchema = { // order: 6, // }, component: { + type: "string", inputProps: { min: 0, max: 255, @@ -80,6 +84,7 @@ const AgentInputsPartialSchema = { order: 7, }, logoUrl: { + type: "string", inputProps: { min: 4, max: 255, @@ -124,6 +129,17 @@ const agentInputsValidator = (formValues) => return !required || typeof formValues[key] === "string"; }); +const initialValues = (schema, data) => { + const initial = data ?? {}; + Object.keys(schema).forEach((key) => { + const fieldProps = schema[key]; + if (!data[key] && fieldProps.displayType !== "hidden" && fieldProps.type === "string") { + initial[key] = ""; + } + }); + return initial; +}; + return ( ); diff --git a/src/AI/Nexus.jsx b/src/AI/Nexus.jsx index 5ad5922c..35e42071 100644 --- a/src/AI/Nexus.jsx +++ b/src/AI/Nexus.jsx @@ -87,7 +87,15 @@ return ( ); diff --git a/src/Entities/QueryApi/Client.jsx b/src/Entities/QueryApi/Client.jsx index ac8c82fd..0041c969 100644 --- a/src/Entities/QueryApi/Client.jsx +++ b/src/Entities/QueryApi/Client.jsx @@ -36,9 +36,28 @@ function loadItems(queries, queryName, offset, collection, onLoad) { }); } +function convertObjectKeysSnakeToPascal(item) { + const newItem = {}; + Object.keys(item).forEach((key) => { + const pascalKey = key.replace(/(_\w)/g, (m) => m[1].toUpperCase()); + newItem[pascalKey] = item[key]; + }); + return newItem; +} +function convertObjectKeysPascalToSnake(item) { + const newItem = {}; + Object.keys(item).forEach((key) => { + const snakeKey = key.replace(/([A-Z])/g, (m) => "_" + m.toLowerCase()); + newItem[snakeKey] = item[key]; + }); + return newItem; +} + return { fetchGraphQL, loadItems, + convertObjectKeysSnakeToPascal, + convertObjectKeysPascalToSnake, LIMIT, GRAPHQL_ENDPOINT, HASURA_ROLE, diff --git a/src/Entities/Template/EntityCreate.jsx b/src/Entities/Template/EntityCreate.jsx index cda87c1f..2af25a4a 100644 --- a/src/Entities/Template/EntityCreate.jsx +++ b/src/Entities/Template/EntityCreate.jsx @@ -1,19 +1,22 @@ -const { data, onSubmit, onCancel, cancelLabel, entityType, schema } = props; +const { data, onSubmit, onCancel, cancelLabel, schema } = props; +const entityType = schema.entityType; const capitalize = (s) => (s ? s.charAt(0).toUpperCase() + s.slice(1) : s); const capitalizedEntityType = capitalize(entityType); -if (!schema || !entityType) { +if (!schema) { return <>Missing properties schema or entityType; } const displayedSchema = Object.keys(schema) - .filter((key) => schema[key]?.displayType !== "hidden") + .filter((key) => typeof schema[key] === "object" && schema[key]?.displayType !== "hidden") .reduce((acc, key) => { if (key) acc[key] = schema[key]; return acc; }, {}); +const namespace = schema.namespace; const onSubmitDefault = (formValues) => { const { name, ...rest } = formValues; const entity = { [name]: rest }; - Social.set({ [entityType]: entity }, { force: true }); + const data = namespace ? { [namespace]: { [entityType]: entity } } : { [entityType]: entity }; + Social.set(data, { force: true }); }; const onSubmitFunction = onSubmit ?? onSubmitDefault; diff --git a/src/Entities/Template/EntityDetails.jsx b/src/Entities/Template/EntityDetails.jsx new file mode 100644 index 00000000..71162556 --- /dev/null +++ b/src/Entities/Template/EntityDetails.jsx @@ -0,0 +1,145 @@ +const { href } = VM.require("${REPL_DEVHUB}/widget/core.lib.url"); +if (!href) { + return <>; +} +let { src, tab, highlightComment, schemaFile, namespace } = props; +const [accountId, entityType, entityName] = src.split("/") ?? [null, null, null]; + +let entity = Social.get(`${accountId}/${namespace}/${entityType}/${entityName}/**`); +const exists = !existsData || Object.keys(existsData).length > 0; +if (!exists) { + return ( +
+
Error
+
Could not find: {src}
+
+ ); +} + +const schemaLocation = schemaFile ? schemaFile : `${REPL_ACCOUNT}/widget/Entities.Template.GenericSchema`; +const { genSchema } = VM.require(schemaLocation, { namespace, entityType }); +if (!genSchema) { + return <>; +} +const schema = genSchema(namespace, entityType); +const { title } = schema; + +entity = { accountId, name: entityName, ...entity }; +const { prompt } = entity; +const editType = accountId === context.accountId ? "edit" : "fork"; +const editLabel = editType === "edit" ? "Edit" : "Fork"; +const editIcon = editType === "edit" ? "ph-bold ph-pencil-simple" : "ph-bold ph-git-fork"; +const listLink = href({ + widgetSrc: `${REPL_AGIGUILD}/widget/Nexus`, +}); + +const Wrapper = styled.div` + display: flex; + flex-direction: column; + gap: 32px; + padding-bottom: 48px; +`; + +const ContentWrapper = styled.div` + display: grid; + grid-template-columns: minmax(0, 1fr) 260px; + gap: 32px; + + @media (max-width: 1200px) { + grid-template-columns: minmax(0, 1fr); + gap: 24px; + } +`; + +const Header = styled.h1` + font-size: 24px; + line-height: 39px; + color: #11181c; + margin: 0; + font-weight: 600; +`; + +const Text = styled.p` + margin: 0; + font-size: 14px; + line-height: 20px; + color: ${(p) => (p.bold ? "#11181C" : "#687076")}; + font-weight: ${(p) => (p.bold ? "600" : "400")}; + font-size: ${(p) => (p.small ? "12px" : "14px")}; + + i { + margin-right: 4px; + } +`; +const PropValue = styled.p` + margin: 0; + font-size: 14px; + line-height: 20px; + padding-bottom: 10px; +`; +const entityProperties = (obj) => { + const { accountId, name, displayName, logoUrl, ...displayProps } = obj; + return ( + <> + {Object.keys(displayProps).map((k) => ( + <> + + {k}: + + {obj[k]} + + ))} + + ); +}; + +return ( + + +
+ + {entityType} List +
+ + + + + ), + icon: editIcon, + }, + ], + }} + /> + + + +
+); diff --git a/src/Entities/Template/EntityList.jsx b/src/Entities/Template/EntityList.jsx index 887db560..7130654a 100644 --- a/src/Entities/Template/EntityList.jsx +++ b/src/Entities/Template/EntityList.jsx @@ -5,17 +5,7 @@ if (!loadItemsQueryApi) { const loadItems = props.loadItems ?? loadItemsQueryApi; const accountId = props.accountId || context.accountId; -const { - entityType, - schema, - description, - buildQueries, - queryName, - collection, - renderItem, - createWidget, - createWidgetProps, -} = props; +const { schema, description, buildQueries, queryName, collection, renderItem, createWidget, createWidgetProps } = props; const finalCreateWidget = createWidget ?? `${REPL_ACCOUNT}/widget/Entities.Template.EntityCreate`; @@ -48,12 +38,6 @@ useEffect(() => { loadItemsUseState(); }, [sort, searchKey]); -const humanize = (str) => { - if (!str) return ""; - return str.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase()); -}; -const humanizedEntityType = humanize(entityType); - const Wrapper = styled.div` display: flex; flex-direction: column; @@ -139,7 +123,7 @@ return (

- {totalItems} {humanizedEntityType + (totalItems > 1 ? "s" : "")} + {totalItems} {schema.entityTitle + (totalItems !== 1 ? "s" : "")}

{description && {description}}
@@ -148,7 +132,7 @@ return ( ), diff --git a/src/Entities/Template/EntitySummary.jsx b/src/Entities/Template/EntitySummary.jsx new file mode 100644 index 00000000..e74d4546 --- /dev/null +++ b/src/Entities/Template/EntitySummary.jsx @@ -0,0 +1,237 @@ +if (!props.entity) return ""; +const { href } = VM.require("${REPL_DEVHUB}/widget/core.lib.url"); +if (!href) { + return <>; +} + +const { entity, showActions } = props; +const { accountId, name, displayName, logoUrl, tags } = entity; +const size = props.size || "small"; + +const sizes = { + small: { + gap: "16px", + thumbnail: "100px", + title: "16px", + }, + large: { + gap: "16px", + thumbnail: "200px", + title: "32px", + }, +}; + +const Wrapper = styled.div``; + +const Header = styled.div` + display: flex; + align-items: center; + gap: ${(p) => sizes[p.size].gap}; + margin-bottom: 32px; + + > * { + min-width: 0; + } + + @media (max-width: 770px) { + gap: 16px; + } +`; + +const TagsWrapper = styled.div` + margin-bottom: 16px; +`; + +const Actions = styled.div` + display: flex; + gap: 12px; + flex-wrap: wrap; + margin-bottom: 16px; +`; + +const Title = styled.h1` + font-size: ${(p) => sizes[p.size].title}; + line-height: 1.2em; + color: #11181c; + margin: 0 0 8px; + font-weight: 600; + + @media (max-width: 770px) { + font-size: 16px; + margin: 0; + } +`; + +const Thumbnail = styled.div` + width: ${(p) => sizes[p.size].thumbnail}; + height: ${(p) => sizes[p.size].thumbnail}; + flex-shrink: 0; + border: 1px solid #eceef0; + border-radius: 12px; + overflow: hidden; + box-shadow: + 0 1px 3px rgba(16, 24, 40, 0.1), + 0 1px 2px rgba(16, 24, 40, 0.06); + + img { + object-fit: cover; + width: 100%; + height: 100%; + } + + @media (max-width: 770px) { + width: 58px; + height: 58px; + } +`; + +const sharedButtonStyles = ` + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 16px; + height: 32px; + border-radius: 50px; + font-weight: 600; + font-size: 12px; + line-height: 15px; + text-align: center; + cursor: pointer; + + &:hover, + &:focus { + text-decoration: none; + outline: none; + } + + i { + color: #7E868C; + } + + .bi-16 { + font-size: 16px; + } +`; + +const Button = styled.button` + ${sharedButtonStyles} + color: ${(p) => (p.primary ? "#09342E" : "#11181C")} !important; + background: ${(p) => (p.primary ? "#59E692" : "#FBFCFD")}; + border: ${(p) => "none"}; + + &:hover, + &:focus { + background: ${(p) => (p.primary ? "rgb(112 242 164)" : "#ECEDEE")}; + } +`; + +const Text = styled.p` + margin: 0; + line-height: 20px; + color: ${(p) => (p.bold ? "#11181C" : "#687076")}; + font-weight: ${(p) => (p.bold ? "600" : "400")}; + font-size: ${(p) => (p.small ? "12px" : "14px")}; + overflow: ${(p) => (p.ellipsis ? "hidden" : "visible")}; + text-overflow: ${(p) => (p.ellipsis ? "ellipsis" : "unset")}; + white-space: ${(p) => (p.ellipsis ? "nowrap" : "")}; + + i { + margin-right: 4px; + } +`; + +return ( + +
+ + + + +
+ {displayName} + {name} + by {accountId} +
+
+ + {tags && tags.length > 0 && ( + + + + )} + + {showActions && ( + + {false && accountId === context.accountId && ( + delete(entity.name), + iconLeft: "ph-bold ph-trash", + variant: "destructive", + fill: "ghost", + size: "small", + }} + /> + ), + }} + /> + )} + + + ( + + ), + }} + />{" "} + + )} +
+); diff --git a/src/Entities/Template/GenericEntityConfig.jsx b/src/Entities/Template/GenericEntityConfig.jsx new file mode 100644 index 00000000..359f2712 --- /dev/null +++ b/src/Entities/Template/GenericEntityConfig.jsx @@ -0,0 +1,196 @@ +const { href } = VM.require("${REPL_DEVHUB}/widget/core.lib.url"); +if (!href) { + return <>; +} + +const { namespace, entityType, title, schemaFile } = props; + +const schemaLocation = schemaFile ? schemaFile : `${REPL_ACCOUNT}/widget/Entities.Template.GenericSchema`; +const { genSchema } = VM.require(schemaLocation, { namespace, entityType }); +if (!genSchema) { + return <>; +} +const schema = genSchema(namespace, entityType); + +const Row = styled.div` + vertical-align: middle; + padding-bottom: 1em; + img.logo { + width: 30%; + aspect-ratio: 1 / 1; + border-radius: 50%; + object-fit: cover; + } +`; +const Actions = styled.div` + display: flex; + align-items: center; + gap: 2px; +`; + +const entityIndexer = "agents"; +const entityTable = "entities"; + +const user = "dataplatform_near"; +const collection = `${user}_${entityIndexer}_${entityTable}`; + +const dataFields = Object.keys(schema).filter((key) => typeof schema[key] === "object"); +const standardFields = ["id", "accountId", "name", "displayName", "logoUrl", "attributes"]; +const attributeFields = dataFields.filter((key) => !standardFields.includes(key)); +const buildQueries = (searchKey, sort) => { + const queryFilter = searchKey ? `name: {_ilike: "%${searchKey}%"}` : ""; + let querySortOption; + switch (sort) { + case "z-a": + querySortOption = `{ name: desc },`; + break; + case "a-z": + querySortOption = `{ name: asc },`; + break; + default: + querySortOption = "{ id: desc },"; + } + return ` +query ListQuery($offset: Int, $limit: Int) { + ${collection}( + where: {entity_type: {_eq: "${entityType}"}, ${queryFilter}} + order_by: [${querySortOption} ], + offset: $offset, limit: $limit) { + id + account_id + name + display_name + logo_url + attributes + } + ${collection}_aggregate ( + where: {entity_type: {_eq: "${entityType}"}, ${queryFilter}} + ){ + aggregate { + count + } + } +} +`; +}; +const queryName = "ListQuery"; + +const convertSnakeToPascal = (item) => { + const newItems = {}; + Object.keys(item).forEach((key) => { + const pascalKey = key.replace(/(_\w)/g, (m) => m[1].toUpperCase()); + newItems[pascalKey] = item[key]; + }); + return newItems; +}; +const convertPascalToSnake = (itemArray) => { + const newItems = []; + itemArray.forEach((key) => { + const snakeKey = key.replace(/([A-Z])/g, (m) => "_" + m.toLowerCase()); + newItems.unshift(snakeKey); + }); + return newItems; +}; + +const renderItem = (rawItem, editFunction) => { + const item = convertSnakeToPascal(rawItem); + const { accountId, name, displayName, logoUrl, attributes } = item; + const itemComponent = item.component ? item.component : `${REPL_AGIGUILD}/widget/Entities.Template.EntityDetails`; + const imageUrl = logoUrl + ? logoUrl + : "https://ipfs.near.social/ipfs/bafkreibysr2mkwhb4j36h2t7mqwhynqdy4vzjfygfkfg65kuspd2bawauu"; + const actionLink = href({ + widgetSrc: itemComponent, + params: { src: `${accountId}/${entityType}/${name}`, schemaFile, namespace }, + }); + const detailsLink = href({ + widgetSrc: `${REPL_AGIGUILD}/widget/Entities.Template.EntityDetails`, + params: { src: `${accountId}/${entityType}/${name}`, schemaFile, namespace }, + }); + + const actionUrl = `https://${REPL_NEAR_URL}/${itemComponent}?src=${accountId}/${entityType}/${item.name}`; + const editType = accountId === context.accountId ? "edit" : "fork"; + const editLabel = editType === "edit" ? "Edit" : "Fork"; + const editIcon = editType === "edit" ? "ph-bold ph-pencil-simple" : "ph-bold ph-git-fork"; + + return ( + +
+ logo +
+
{displayName}
+
{accountId}
+
+ {attributeFields.map((key) => ( + {attributes[key]} + ))} +
+
+ + editFunction(item), + iconLeft: editIcon, + variant: "secondary", + fill: "ghost", + size: "small", + }} + /> + ), + }} + /> + + + + + + ), + }} + /> + +
+
+ ); +}; + +const createWidget = "${REPL_ACCOUNT}/widget/Entities.Template.EntityCreate"; + +return ( +
+

{title}

+ +
+); diff --git a/src/Entities/Template/GenericSchema.jsx b/src/Entities/Template/GenericSchema.jsx new file mode 100644 index 00000000..f2ce16ed --- /dev/null +++ b/src/Entities/Template/GenericSchema.jsx @@ -0,0 +1,58 @@ +const humanize = (str) => { + if (!str) return ""; + return str.replace(/([A-Z])/g, " $1").replace(/^./, (s) => s.toUpperCase()); +}; + +const genSchema = (namespace, entityType, entityTitle) => { + const title = entityTitle || humanize(entityType); + return { + namespace: namespace, + entityType: entityType, + entityTitle: title, + id: { + type: "integer", + displayType: "hidden", + }, + accountId: { + type: "text", + displayType: "hidden", + }, + name: { + type: "string", + inputProps: { + min: 2, + max: 80, + allowCommaAndSpace: false, + placeholder: `Choose a unique identifier for your ${title}.`, + required: true, + }, + label: "Name", + order: 1, + }, + displayName: { + type: "string", + inputProps: { + min: 2, + max: 255, + placeholder: "The name that will be displayed to users.", + required: true, + }, + label: "Display Name", + order: 2, + }, + logoUrl: { + type: "string", + inputProps: { + min: 4, + max: 255, + placeholder: `The logo URL for the ${title}.`, + required: false, + }, + + label: "Logo URL", + order: 5, + }, + }; +}; + +return { genSchema };