Fix CSS layout width and add more user friendly messages

This commit is contained in:
khalid@traclabs.com
2026-04-23 01:10:54 -05:00
parent 5229ccdb0f
commit c61f3acae9
6 changed files with 86 additions and 45 deletions

View File

@@ -128,7 +128,7 @@ Return a JSON object with EXACTLY these fields:
- "repo_relative_path": a STRING — the path of the target section file from the manifest (e.g. "content/sections/hero.json"). NEVER null or empty. - "repo_relative_path": a STRING — the path of the target section file from the manifest (e.g. "content/sections/hero.json"). NEVER null or empty.
- "needs_clarification": a boolean — true only if the request is genuinely ambiguous between multiple sections - "needs_clarification": a boolean — true only if the request is genuinely ambiguous between multiple sections
- "reason": a short string explaining the routing decision - "reason": a short string explaining the routing decision
- "clarification_message": a string (only when needs_clarification is true) — a question to ask the user - "clarification_message": a string (only when needs_clarification is true) — a friendly question to ask the user, referring to sections by their display names (e.g. "Our Story", "What We Offer") not filenames
Rules: Rules:
- "repo_relative_path" MUST be one of the paths listed in the MANIFEST. Copy it exactly. - "repo_relative_path" MUST be one of the paths listed in the MANIFEST. Copy it exactly.
@@ -208,12 +208,16 @@ export async function generateInfoResponse(params: GenerateInfoResponseParams, c
const messages = [ const messages = [
{ {
role: 'system', role: 'system',
content: `You are a helpful assistant for a website owner. They asked a question about their website's content. content: `You are a friendly assistant helping a small business owner manage their website via text message.
Answer their question based on the MANIFEST of sections below. Keep your response under 300 characters (it will be sent via SMS). Be concise and specific. No markdown, just plain text. They asked a question about their website. Answer based on the MANIFEST below.
If they ask what's on their site, list the visible sections briefly. Rules:
If they ask about a specific section, describe its content briefly.`, - Keep your response under 300 characters (this is an SMS).
- Use plain, conversational language — like you're texting a friend.
- Refer to sections by their display names (e.g. "your About section", "the hero banner"), never by filenames or technical terms.
- If a section is hidden (visible: false), mention it's currently hidden from visitors.
- Be warm and helpful.`,
}, },
{ {
role: 'user', role: 'user',
@@ -228,11 +232,11 @@ If they ask about a specific section, describe its content briefly.`,
// Fallback: generate a basic listing from the manifest // Fallback: generate a basic listing from the manifest
const visibleSections = params.manifest.filter(m => m.visible); const visibleSections = params.manifest.filter(m => m.visible);
const sectionNames = visibleSections.map(m => m.title || m.headline || m.heading || m.id).join(', '); const sectionNames = visibleSections.map(m => m.title || m.headline || m.heading || m.id).join(', ');
return `Your site has these sections: ${sectionNames}`; return `Your site currently shows: ${sectionNames}. Want to change anything?`;
} }
} }
/** Simple summary generation (no schema validation needed) */ /** Friendly summary generation for SMS confirmations */
export async function generateSummary(params: { export async function generateSummary(params: {
before: unknown; before: unknown;
after: unknown; after: unknown;
@@ -244,11 +248,29 @@ export async function generateSummary(params: {
const messages = [ const messages = [
{ {
role: 'system', role: 'system',
content: `You summarize content changes for a website owner. Keep summaries under 140 characters, plain text, no markdown. Be specific about what changed. Format: "Change X from A to B" or "Add/remove X".`, content: `You write short, friendly summaries of website changes for a small business owner who manages their site via text message.
Rules:
- Keep it under 140 characters.
- Describe what visitors will SEE on the website, not what changed in the data.
- Use plain, everyday language. No technical terms, no field names, no file paths, no JSON jargon.
- Write in a warm, conversational tone — like texting a friend.
Good examples:
- "Show the promo banner on your site"
- "Update your main headline to 'Welcome Home'"
- "Hide the testimonials section from visitors"
- "Add a new event: Wine Tasting on June 15"
- "Change the About section text"
Bad examples (too technical — never do this):
- "Change promo-banner visible from false to true"
- "Update hero.headline field"
- "Modify content/sections/about.json"`,
}, },
{ {
role: 'user', role: 'user',
content: `File: ${params.repoRelativePath}\nRequest: "${params.userMessage}"\n\nBefore:\n${JSON.stringify(params.before, null, 2)}\n\nAfter:\n${JSON.stringify(params.after, null, 2)}\n\nSummarize the change in under 140 chars:`, content: `The owner asked: "${params.userMessage}"\n\nBefore:\n${JSON.stringify(params.before, null, 2)}\n\nAfter:\n${JSON.stringify(params.after, null, 2)}\n\nWrite a friendly summary of what this change does to their website:`,
}, },
]; ];
@@ -256,7 +278,7 @@ export async function generateSummary(params: {
const result = await chat(messages, PRIMARY_MODEL); const result = await chat(messages, PRIMARY_MODEL);
return result.replace(/["'`]/g, '').trim().slice(0, 280); return result.replace(/["'`]/g, '').trim().slice(0, 280);
} catch { } catch {
// Fallback: generate a basic diff summary // Fallback: echo back the user's original request
return `Update ${params.repoRelativePath} as requested: "${params.userMessage.slice(0, 80)}"`; return `${params.userMessage.charAt(0).toUpperCase() + params.userMessage.slice(1, 100)}`;
} }
} }

View File

@@ -6,13 +6,23 @@ import { schemaForRepoRelativePath } from '@dynamic-sites/shared';
import { createProposal, getProposal, updateProposalStatus } from '../db.js'; import { createProposal, getProposal, updateProposalStatus } from '../db.js';
import { writeContentFile } from '../io/write-content.js'; import { writeContentFile } from '../io/write-content.js';
import { generateEditedJson, routeEditIntent, generateSummary, classifyMessageIntent, generateInfoResponse } from '../llm/client.js'; import { generateEditedJson, routeEditIntent, generateSummary, classifyMessageIntent, generateInfoResponse } from '../llm/client.js';
import { buildSectionManifest } from './manifest.js'; import { buildSectionManifest, type ManifestEntry } from './manifest.js';
import { sendSms } from '../sms/reply.js'; import { sendSms } from '../sms/reply.js';
import { SMS_TEMPLATES } from '../sms/templates.js'; import { SMS_TEMPLATES } from '../sms/templates.js';
import { logger } from '../logger.js'; import { logger } from '../logger.js';
const REPO_ROOT = process.env.REPO_ROOT || '.'; const REPO_ROOT = process.env.REPO_ROOT || '.';
/** Get a friendly display name for a manifest entry. */
function sectionDisplayName(m: ManifestEntry): string {
return m.title || m.headline || m.heading || m.id;
}
/** Get a comma-separated list of friendly section names from a manifest. */
function sectionNameList(manifest: ManifestEntry[]): string {
return manifest.map(sectionDisplayName).join(', ');
}
/** /**
* In-memory map from job ID → proposal ID. * In-memory map from job ID → proposal ID.
* Used by the HTTP API to let the editor poll for a proposal created by a queued job. * Used by the HTTP API to let the editor poll for a proposal created by a queued job.
@@ -80,7 +90,7 @@ async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>)
log.info({ event: 'routing.ambiguous' }, 'Routing ambiguous'); log.info({ event: 'routing.ambiguous' }, 'Routing ambiguous');
if (job.smsReplyMeta) { if (job.smsReplyMeta) {
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to,
routing.clarification_message || SMS_TEMPLATES.ROUTING_AMBIGUOUS(manifest.map(m => m.id).join(', ')) routing.clarification_message || SMS_TEMPLATES.ROUTING_AMBIGUOUS(sectionNameList(manifest))
); );
} }
return; return;
@@ -96,7 +106,8 @@ async function handlePropose(job: Extract<EditJobPayload, { kind: 'propose' }>)
if (!fs.existsSync(absPath)) { if (!fs.existsSync(absPath)) {
log.error({ event: 'propose.file_not_found', path: repoRelativePath }, 'Target file not found'); log.error({ event: 'propose.file_not_found', path: repoRelativePath }, 'Target file not found');
if (job.smsReplyMeta) { if (job.smsReplyMeta) {
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.ROUTING_NO_MATCH(repoRelativePath)); const manifest = buildSectionManifest();
await sendSms(job.smsReplyMeta.from, job.smsReplyMeta.to, SMS_TEMPLATES.ROUTING_NO_MATCH(sectionNameList(manifest)));
} }
return; return;
} }

View File

@@ -1,37 +1,37 @@
export const SMS_TEMPLATES = { export const SMS_TEMPLATES = {
PROPOSAL_SUMMARY: (summary: string, _proposalId: string) => PROPOSAL_SUMMARY: (summary: string, _proposalId: string) =>
`Proposed change: ${summary}\n\nReply YES to apply or NO to cancel.`, `I'd like to: ${summary}\n\nReply YES to apply or NO to cancel.`,
APPLIED: (summary: string) => APPLIED: (summary: string) =>
`Done! ${summary} Your site will update shortly.`, `Done! ${summary} Your site will update in a moment.`,
REJECTED: () => REJECTED: () =>
`Got it — change cancelled. Send a new message anytime.`, `No problem — I cancelled that change. Just text me whenever you'd like to make an edit!`,
LLM_UNAVAILABLE: () => LLM_UNAVAILABLE: () =>
`Sorry, I couldn't process that right now. Please try again in a few minutes.`, `Sorry, I'm having trouble processing that right now. Could you try again in a few minutes?`,
ROUTING_AMBIGUOUS: (options: string) => ROUTING_AMBIGUOUS: (options: string) =>
`I'm not sure which section you mean. Did you mean: ${options}? Reply with the number or name.`, `I'm not sure which part of your site you mean. Could you be more specific? Your site has: ${options}`,
ROUTING_NO_MATCH: (list: string) => ROUTING_NO_MATCH: (list: string) =>
`I couldn't find a section matching that request. Your current sections are: ${list}. Try again?`, `I couldn't find a matching section on your site. You currently have: ${list}. Want to try again?`,
PROPOSAL_EXPIRED: () => PROPOSAL_EXPIRED: () =>
`That change request has expired. Please send your edit again to start over.`, `That change request has expired. Just send your edit again and I'll set it up!`,
PROPOSAL_ALREADY_APPLIED: () => PROPOSAL_ALREADY_APPLIED: () =>
`That change was already applied.`, `That change was already made to your site!`,
INVALID_CONFIRM: () => INVALID_CONFIRM: () =>
`Reply YES to apply or NO to cancel.`, `Just reply YES to apply or NO to cancel.`,
RATE_LIMITED: () => RATE_LIMITED: () =>
`You've sent several requests recently. Please wait a few minutes before trying again.`, `You've been busy! Give me a few minutes to catch up, then try again.`,
MMS_NOT_SUPPORTED: () => MMS_NOT_SUPPORTED: () =>
`Image uploads aren't supported yet. Please describe your change in text.`, `I can't handle images yet — just describe what you'd like to change in a text message!`,
HELP: () => HELP: () =>
`I can help you edit your website via text! Just tell me what to change. Examples:\n- "Change the hero headline to Welcome Home"\n- "Hide the testimonials section"\n- "Add an event: Wine Tasting, June 15, 7pm"\n- "What does my about section say?"\n\nAfter each edit, I'll show you the change and you reply YES to apply or NO to cancel.`, `I can help you update your website right from here! Just text me what you'd like to change. For example:\n\n- "Change the headline to Welcome Home"\n- "Hide the testimonials"\n- "Add an event: Wine Tasting, June 15, 7pm"\n- "What's on my site right now?"\n\nI'll show you the change first, and you reply YES to make it live or NO to cancel.`,
} as const; } as const;

View File

@@ -20,14 +20,13 @@ const { headline, subheading, ctaText, ctaLink } = Astro.props;
<style> <style>
.hero { .hero {
padding: 5rem 0 4rem; padding: 5rem 1.5rem 4rem;
text-align: center; text-align: center;
background: linear-gradient( background: linear-gradient(
180deg, 180deg,
color-mix(in srgb, var(--color-primary), white 92%) 0%, color-mix(in srgb, var(--color-primary), white 92%) 0%,
var(--color-bg) 100% var(--color-bg) 100%
); );
border-bottom: none;
} }
.hero h1 { .hero h1 {
font-family: var(--font-display); font-family: var(--font-display);

View File

@@ -30,12 +30,7 @@ const isPromo = id.includes('promo') || id.includes('banner');
} }
.promo { .promo {
background: color-mix(in srgb, var(--color-primary), white 90%); background: color-mix(in srgb, var(--color-primary), white 90%);
border-left: 4px solid var(--color-primary); border-bottom: 3px solid var(--color-primary);
border-radius: 0 6px 6px 0; padding: 2rem 1.5rem;
margin: 0 auto;
}
.promo .container {
padding-top: 0.5rem;
padding-bottom: 0.5rem;
} }
</style> </style>

View File

@@ -15,7 +15,7 @@ const { title, primaryColor = '#2d5016' } = Astro.props;
<link rel="preconnect" href="https://fonts.googleapis.com" /> <link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin /> <link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Source+Sans+3:wght@300;400;500;600&display=swap" rel="stylesheet" /> <link href="https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=Source+Sans+3:wght@300;400;500;600&display=swap" rel="stylesheet" />
<style define:vars={{ primaryColor }}> <style is:global define:vars={{ primaryColor }}>
:root { :root {
--color-primary: var(--primaryColor); --color-primary: var(--primaryColor);
--color-primary-dark: color-mix(in srgb, var(--primaryColor), black 20%); --color-primary-dark: color-mix(in srgb, var(--primaryColor), black 20%);
@@ -44,8 +44,28 @@ const { title, primaryColor = '#2d5016' } = Astro.props;
padding: 0 1.5rem; padding: 0 1.5rem;
} }
/* Default: sections are constrained */
section {
max-width: 1080px;
margin-left: auto;
margin-right: auto;
padding: 3.5rem 1.5rem;
}
section + section {
border-top: 1px solid var(--color-border);
}
/* Full-bleed sections: hero and promo break out to full width */
section.hero,
section.promo {
max-width: none;
padding-left: 0;
padding-right: 0;
border-top: none;
}
.site-header { .site-header {
padding: 1.25rem 0; padding: 1.25rem 1.5rem;
border-bottom: 1px solid var(--color-border); border-bottom: 1px solid var(--color-border);
background: white; background: white;
} }
@@ -53,6 +73,7 @@ const { title, primaryColor = '#2d5016' } = Astro.props;
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
max-width: 1080px;
} }
.site-logo { .site-logo {
font-family: var(--font-display); font-family: var(--font-display);
@@ -69,19 +90,12 @@ const { title, primaryColor = '#2d5016' } = Astro.props;
.site-footer { .site-footer {
margin-top: 4rem; margin-top: 4rem;
padding: 2rem 0; padding: 2rem 1.5rem;
border-top: 1px solid var(--color-border); border-top: 1px solid var(--color-border);
text-align: center; text-align: center;
font-size: 0.85rem; font-size: 0.85rem;
color: var(--color-text-muted); color: var(--color-text-muted);
} }
section {
padding: 3.5rem 0;
}
section + section {
border-top: 1px solid var(--color-border);
}
</style> </style>
</head> </head>
<body> <body>