mirror of
https://github.com/different-ai/openwork
synced 2026-04-25 17:15:34 +02:00
fix(den): parse skill frontmatter when saving skills (#1308)
* fix(den): parse skill frontmatter when saving skills Keep the full SKILL.md payload in the database, derive title and description from frontmatter, and require a frontmatter name in the API. Also include shared packages in the Den web Docker image so the upload flow can be verified end to end. * fix(den-web): transpile shared utils package Include @openwork-ee/utils in Next's transpilePackages list so the client skill editor resolves the shared frontmatter helpers during production builds. * fix(den-web): keep skill parsing local to the app Move the client-side skill markdown parser into the Den web app so production builds do not depend on resolving a workspace package boundary from a client component. * refactor(den): share skill frontmatter parsing via utils Make Den web and Den API both depend on @openwork-ee/utils for skill frontmatter parsing, refresh the workspace lockfile, and keep the Den web build wiring explicit for CI and Docker. * fix(den-web): build Den from the monorepo root on Vercel Keep the shared utils package setup, but make Vercel install and build from the repo root so workspace dependencies like @openwork-ee/utils are always available when Den web deploys from its app subdirectory. * docs(den-web): keep Vercel workspace settings in the dashboard Remove the app-level Vercel command override from the repo and document the monorepo install/build settings for the dashboard instead. --------- Co-authored-by: src-opn <src-opn@users.noreply.github.com>
This commit is contained in:
@@ -8,6 +8,7 @@ import {
|
||||
SkillTable,
|
||||
TeamTable,
|
||||
} from "@openwork-ee/den-db/schema"
|
||||
import { hasSkillFrontmatterName, parseSkillMarkdown } from "@openwork-ee/utils"
|
||||
import { createDenTypeId, normalizeDenTypeId } from "@openwork-ee/utils/typeid"
|
||||
import type { Hono } from "hono"
|
||||
import { z } from "zod"
|
||||
@@ -23,13 +24,30 @@ import type { MemberTeamsContext } from "../../middleware/member-teams.js"
|
||||
import type { OrgRouteVariables } from "./shared.js"
|
||||
import { idParamSchema, memberHasRole, orgIdParamSchema } from "./shared.js"
|
||||
|
||||
const skillTextSchema = z.string().superRefine((value, ctx) => {
|
||||
if (!value.trim()) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Skill content cannot be empty.",
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
if (!hasSkillFrontmatterName(value)) {
|
||||
ctx.addIssue({
|
||||
code: z.ZodIssueCode.custom,
|
||||
message: "Skill content must start with frontmatter that includes a name.",
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const createSkillSchema = z.object({
|
||||
skillText: z.string().trim().min(1),
|
||||
skillText: skillTextSchema,
|
||||
shared: z.enum(["org", "public"]).nullable().optional(),
|
||||
})
|
||||
|
||||
const updateSkillSchema = z.object({
|
||||
skillText: z.string().trim().min(1).optional(),
|
||||
skillText: skillTextSchema.optional(),
|
||||
shared: z.enum(["org", "public"]).nullable().optional(),
|
||||
}).superRefine((value, ctx) => {
|
||||
if (value.skillText === undefined && value.shared === undefined) {
|
||||
@@ -111,6 +129,17 @@ function parseTeamId(value: string) {
|
||||
}
|
||||
|
||||
function parseSkillMetadata(skillText: string) {
|
||||
const parsed = parseSkillMarkdown(skillText)
|
||||
if (parsed.hasFrontmatter) {
|
||||
const title = parsed.name.trim() || "Untitled skill"
|
||||
const description = parsed.description.trim() || null
|
||||
|
||||
return {
|
||||
title: title.slice(0, 255),
|
||||
description: description ? description.slice(0, 65535) : null,
|
||||
}
|
||||
}
|
||||
|
||||
const lines = skillText
|
||||
.split(/\r?\n/g)
|
||||
.map((line) => line.trim())
|
||||
|
||||
@@ -54,9 +54,11 @@ Recommended project settings:
|
||||
|
||||
- Root directory: `ee/apps/den-web`
|
||||
- Framework preset: Next.js
|
||||
- Build command: `next build`
|
||||
- Build command: `cd ../../.. && pnpm --filter @openwork-ee/den-web build`
|
||||
- Output directory: `.next`
|
||||
- Install command: `pnpm install --frozen-lockfile`
|
||||
- Install command: `cd ../../.. && pnpm install --frozen-lockfile`
|
||||
|
||||
These commands should be configured in the Vercel dashboard rather than committed in `vercel.json`, so the app still builds from the monorepo root and can resolve shared workspace packages like `@openwork-ee/utils`.
|
||||
|
||||
Then assign custom domain:
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
import { useOrgDashboard } from "../_providers/org-dashboard-provider";
|
||||
import {
|
||||
buildSkillText,
|
||||
getSkillBodyText,
|
||||
parseSkillDraft,
|
||||
useOrgSkillLibrary,
|
||||
} from "./skill-hub-data";
|
||||
@@ -67,7 +68,10 @@ export function SkillEditorScreen({ skillId }: { skillId?: string }) {
|
||||
});
|
||||
setName(draft.name || skill.title);
|
||||
setDescription(draft.description || skill.description || "");
|
||||
setDetails(draft.details || skill.skillText);
|
||||
setDetails(getSkillBodyText(skill.skillText, {
|
||||
name: skill.title,
|
||||
description: skill.description,
|
||||
}));
|
||||
setVisibility(
|
||||
skill.shared === "org" ? "org" : skill.shared === "public" ? "public" : "private",
|
||||
);
|
||||
@@ -129,7 +133,7 @@ export function SkillEditorScreen({ skillId }: { skillId?: string }) {
|
||||
setUploadedFileName(file.name);
|
||||
setName(draft.name || file.name.replace(/\.md$/i, ""));
|
||||
setDescription(draft.description);
|
||||
setDetails(draft.details || text);
|
||||
setDetails(getSkillBodyText(text));
|
||||
setMode("upload");
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { composeSkillMarkdown, parseSkillMarkdown } from "@openwork-ee/utils";
|
||||
import { getErrorMessage, requestJson } from "../../../../_lib/den-flow";
|
||||
|
||||
export type DenSkillShared = "org" | "public" | null;
|
||||
@@ -220,6 +221,16 @@ export function parseSkillCategory(skillText: string): string | null {
|
||||
}
|
||||
|
||||
export function parseSkillDraft(skillText: string, fallback?: { name?: string | null; description?: string | null }): SkillComposerDraft {
|
||||
const parsed = parseSkillMarkdown(skillText);
|
||||
if (parsed.hasFrontmatter) {
|
||||
return {
|
||||
name: parsed.name || fallback?.name || "",
|
||||
description: parsed.description || fallback?.description || "",
|
||||
category: parseSkillCategory(skillText) ?? "General",
|
||||
details: parsed.body.trim(),
|
||||
};
|
||||
}
|
||||
|
||||
const lines = skillText.split(/\r?\n/g);
|
||||
const nonEmptyIndexes = lines.reduce<number[]>((indexes, line, index) => {
|
||||
if (line.trim()) {
|
||||
@@ -252,6 +263,11 @@ export function parseSkillDraft(skillText: string, fallback?: { name?: string |
|
||||
}
|
||||
|
||||
export function getSkillBodyText(skillText: string, fallback?: { name?: string | null; description?: string | null }) {
|
||||
const parsed = parseSkillMarkdown(skillText);
|
||||
if (parsed.hasFrontmatter) {
|
||||
return parsed.body.trim();
|
||||
}
|
||||
|
||||
const draft = parseSkillDraft(skillText, fallback);
|
||||
return draft.details || skillText;
|
||||
}
|
||||
@@ -284,21 +300,7 @@ function cleanupSkillMetadataLine(value: string): string {
|
||||
}
|
||||
|
||||
export function buildSkillText(input: SkillComposerDraft): string {
|
||||
const sections = [`# ${input.name.trim()}`];
|
||||
|
||||
if (input.description.trim()) {
|
||||
sections.push(input.description.trim());
|
||||
}
|
||||
|
||||
if (input.category.trim()) {
|
||||
sections.push(`Category: ${input.category.trim()}`);
|
||||
}
|
||||
|
||||
if (input.details.trim()) {
|
||||
sections.push(input.details.trim());
|
||||
}
|
||||
|
||||
return `${sections.join("\n\n")}\n`;
|
||||
return composeSkillMarkdown(input.name, input.description, input.details);
|
||||
}
|
||||
|
||||
export function getSkillVisibilityLabel(shared: DenSkillShared): string {
|
||||
|
||||
@@ -4,7 +4,7 @@ const path = require("path");
|
||||
const nextConfig = {
|
||||
reactStrictMode: true,
|
||||
skipTrailingSlashRedirect: true,
|
||||
transpilePackages: ["@openwork/ui"],
|
||||
transpilePackages: ["@openwork/ui", "@openwork-ee/utils"],
|
||||
outputFileTracingRoot: path.join(__dirname, "../../.."),
|
||||
};
|
||||
|
||||
|
||||
@@ -5,12 +5,13 @@
|
||||
"scripts": {
|
||||
"dev": "OPENWORK_DEV_MODE=1 NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_API_KEY= next dev --hostname 0.0.0.0 --port 3005",
|
||||
"dev:local": "sh -lc 'OPENWORK_DEV_MODE=1 NEXT_PUBLIC_POSTHOG_KEY= NEXT_PUBLIC_POSTHOG_API_KEY= next dev --hostname 0.0.0.0 --port ${DEN_WEB_PORT:-3005}'",
|
||||
"prebuild": "pnpm --dir ../../../packages/ui build",
|
||||
"prebuild": "pnpm --dir ../../../packages/ui build && pnpm --dir ../../packages/utils build",
|
||||
"build": "next build",
|
||||
"start": "next start --hostname 0.0.0.0 --port 3005",
|
||||
"lint": "next lint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@openwork-ee/utils": "workspace:*",
|
||||
"@openwork/ui": "workspace:*",
|
||||
"@paper-design/shaders-react": "0.0.72",
|
||||
"lucide-react": "^0.577.0",
|
||||
|
||||
@@ -1 +1,2 @@
|
||||
export * from "./typeid"
|
||||
export * from "./skill-markdown"
|
||||
|
||||
109
ee/packages/utils/src/skill-markdown.ts
Normal file
109
ee/packages/utils/src/skill-markdown.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
export type ParsedSkillMarkdown = {
|
||||
name: string
|
||||
description: string
|
||||
body: string
|
||||
hasFrontmatter: boolean
|
||||
}
|
||||
|
||||
const SKILL_FRONTMATTER_PATTERN = /^---\n([\s\S]*?)\n---\n?/
|
||||
|
||||
function normalizeSkillText(content: string): string {
|
||||
return String(content ?? "").replace(/^\uFEFF/, "").replace(/\r\n/g, "\n")
|
||||
}
|
||||
|
||||
function normalizeFrontmatterValue(value: string | undefined): string {
|
||||
const normalized = String(value ?? "").trim()
|
||||
if (!normalized) {
|
||||
return ""
|
||||
}
|
||||
|
||||
if (normalized.startsWith('"') && normalized.endsWith('"')) {
|
||||
try {
|
||||
const parsed = JSON.parse(normalized)
|
||||
return typeof parsed === "string" ? parsed.trim() : normalized
|
||||
} catch {
|
||||
return normalized.slice(1, -1).trim()
|
||||
}
|
||||
}
|
||||
|
||||
if (normalized.startsWith("'") && normalized.endsWith("'")) {
|
||||
return normalized.slice(1, -1).trim()
|
||||
}
|
||||
|
||||
return normalized
|
||||
}
|
||||
|
||||
function parseFrontmatter(header: string): Record<string, string> {
|
||||
const data: Record<string, string> = {}
|
||||
|
||||
for (const line of header.split("\n")) {
|
||||
const trimmed = line.trim()
|
||||
if (!trimmed) {
|
||||
continue
|
||||
}
|
||||
|
||||
const match = trimmed.match(/^([A-Za-z0-9_-]+)\s*:\s*(.*)$/)
|
||||
if (!match) {
|
||||
continue
|
||||
}
|
||||
|
||||
const key = match[1]?.trim().toLowerCase()
|
||||
if (!key) {
|
||||
continue
|
||||
}
|
||||
|
||||
data[key] = normalizeFrontmatterValue(match[2])
|
||||
}
|
||||
|
||||
return data
|
||||
}
|
||||
|
||||
export function yamlValue(value: string): string {
|
||||
const normalized = String(value ?? "").trim()
|
||||
if (/^[A-Za-z0-9._/\- ]+$/.test(normalized) && normalized && !normalized.includes(":")) {
|
||||
return normalized
|
||||
}
|
||||
return JSON.stringify(normalized)
|
||||
}
|
||||
|
||||
export function parseSkillMarkdown(content: string): ParsedSkillMarkdown {
|
||||
const text = normalizeSkillText(content)
|
||||
const match = text.match(SKILL_FRONTMATTER_PATTERN)
|
||||
if (!match) {
|
||||
return {
|
||||
name: "",
|
||||
description: "",
|
||||
body: text,
|
||||
hasFrontmatter: false,
|
||||
}
|
||||
}
|
||||
|
||||
const header = match[1] ?? ""
|
||||
const data = parseFrontmatter(header)
|
||||
|
||||
return {
|
||||
name: normalizeFrontmatterValue(data.name),
|
||||
description: normalizeFrontmatterValue(data.description),
|
||||
body: text.slice(match[0].length),
|
||||
hasFrontmatter: true,
|
||||
}
|
||||
}
|
||||
|
||||
export function hasSkillFrontmatterName(content: string): boolean {
|
||||
const parsed = parseSkillMarkdown(content)
|
||||
return parsed.hasFrontmatter && Boolean(parsed.name.trim())
|
||||
}
|
||||
|
||||
export function composeSkillMarkdown(name: string, description: string, body: string): string {
|
||||
const normalizedName = String(name ?? "").trim()
|
||||
const normalizedDescription = String(description ?? "").trim()
|
||||
const normalizedBody = normalizeSkillText(body).trim()
|
||||
const frontmatter = [
|
||||
"---",
|
||||
`name: ${yamlValue(normalizedName)}`,
|
||||
...(normalizedDescription ? [`description: ${yamlValue(normalizedDescription)}`] : []),
|
||||
"---",
|
||||
].join("\n")
|
||||
|
||||
return normalizedBody ? `${frontmatter}\n\n${normalizedBody}\n` : `${frontmatter}\n`
|
||||
}
|
||||
@@ -7,10 +7,14 @@ WORKDIR /app
|
||||
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml /app/
|
||||
COPY .npmrc /app/.npmrc
|
||||
COPY patches /app/patches
|
||||
COPY packages/ui/package.json /app/packages/ui/package.json
|
||||
COPY ee/packages/utils/package.json /app/ee/packages/utils/package.json
|
||||
COPY ee/apps/den-web/package.json /app/ee/apps/den-web/package.json
|
||||
|
||||
RUN pnpm install --frozen-lockfile --filter @openwork-ee/den-web...
|
||||
|
||||
COPY packages/ui /app/packages/ui
|
||||
COPY ee/packages/utils /app/ee/packages/utils
|
||||
COPY ee/apps/den-web /app/ee/apps/den-web
|
||||
|
||||
WORKDIR /app/ee/apps/den-web
|
||||
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -426,6 +426,9 @@ importers:
|
||||
|
||||
ee/apps/den-web:
|
||||
dependencies:
|
||||
'@openwork-ee/utils':
|
||||
specifier: workspace:*
|
||||
version: link:../../packages/utils
|
||||
'@openwork/ui':
|
||||
specifier: workspace:*
|
||||
version: link:../../../packages/ui
|
||||
|
||||
Reference in New Issue
Block a user