From 2090a40b59c26d1349de2f942e099797f7f7476a Mon Sep 17 00:00:00 2001 From: Artem Zhiganov Date: Sat, 21 Feb 2026 16:44:29 +0100 Subject: [PATCH 01/17] =?UTF-8?q?feat:=20add=20HARMONICA.md=20=E2=80=94=20?= =?UTF-8?q?persistent=20org=20context=20for=20AI=20facilitator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add conversational AI onboarding that generates a HARMONICA.md document with 8 sections (About, Goals, Participants, Vocabulary, Prior Decisions, Facilitation Preferences, Constraints, Success Patterns). Includes: - DB migration (036) adding harmonica_md column to users table - Onboarding chat API route with LLM-powered questionnaire - OnboardingChat component (chatting → reviewing → saved flow) - Settings editor tab with collapsible section cards - Dashboard integration showing onboarding for new users - Runtime injection into facilitation prompts via session owner lookup Addresses HAR-16, HAR-17, HAR-18. Co-Authored-By: Claude Opus 4.6 --- src/app/(dashboard)/WelcomeBannerRight.tsx | 60 +++ src/app/(dashboard)/page.tsx | 26 +- src/app/api/llamaUtils.ts | 20 +- src/app/api/onboarding/chat/route.ts | 93 +++++ src/app/settings/HarmonicaMdTab.tsx | 322 ++++++++++++++++ src/app/settings/actions.ts | 45 +++ src/app/settings/page.tsx | 7 + src/components/OnboardingChat.tsx | 351 ++++++++++++++++++ .../036_20260221_add_harmonica_md.ts | 15 + src/lib/db.ts | 27 ++ src/lib/schema.ts | 1 + 11 files changed, 951 insertions(+), 16 deletions(-) create mode 100644 src/app/(dashboard)/WelcomeBannerRight.tsx create mode 100644 src/app/api/onboarding/chat/route.ts create mode 100644 src/app/settings/HarmonicaMdTab.tsx create mode 100644 src/components/OnboardingChat.tsx create mode 100644 src/db/migrations/036_20260221_add_harmonica_md.ts diff --git a/src/app/(dashboard)/WelcomeBannerRight.tsx b/src/app/(dashboard)/WelcomeBannerRight.tsx new file mode 100644 index 0000000..b4cd72d --- /dev/null +++ b/src/app/(dashboard)/WelcomeBannerRight.tsx @@ -0,0 +1,60 @@ +'use client'; + +import { useState, useEffect } from 'react'; +import { Button } from '@/components/ui/button'; +import { FileText } from 'lucide-react'; +import Link from 'next/link'; +import { useRouter } from 'next/navigation'; +import CreateSessionInputClient from './CreateSessionInputClient'; +import OnboardingChat from '@/components/OnboardingChat'; + +const SKIP_KEY = 'harmonica_onboarding_skipped'; + +interface WelcomeBannerRightProps { + showOnboarding: boolean; +} + +export default function WelcomeBannerRight({ showOnboarding }: WelcomeBannerRightProps) { + const [showChat, setShowChat] = useState(false); + const router = useRouter(); + + useEffect(() => { + if (showOnboarding) { + const skipped = localStorage.getItem(SKIP_KEY); + if (!skipped) { + setShowChat(true); + } + } + }, [showOnboarding]); + + if (showChat) { + return ( + { + setShowChat(false); + router.refresh(); + }} + onSkip={() => { + localStorage.setItem(SKIP_KEY, '1'); + setShowChat(false); + }} + embedded + /> + ); + } + + return ( + <> +
+ + + + +
+ + + ); +} diff --git a/src/app/(dashboard)/page.tsx b/src/app/(dashboard)/page.tsx index 4bdb7ae..b0f96df 100644 --- a/src/app/(dashboard)/page.tsx +++ b/src/app/(dashboard)/page.tsx @@ -17,6 +17,7 @@ import { getSession } from '@auth0/nextjs-auth0'; import ProjectsGrid from './ProjectsGrid'; import { Textarea } from '@/components/ui/textarea'; import CreateSessionInputClient from './CreateSessionInputClient'; +import WelcomeBannerRight from './WelcomeBannerRight'; export const dynamic = 'force-dynamic'; // getHostSessions is using auth, which can only be done client side export const revalidate = 300; // Revalidate the data every 5 minutes (or on page reload) @@ -30,7 +31,7 @@ const sessionCache = cache(async () => { if (!userId) { console.warn('No user ID found'); - return { hostSessions: [], workspacesWithSessions: [], hasApiKeys: false }; + return { hostSessions: [], workspacesWithSessions: [], hasApiKeys: false, hasHarmonicaMd: false }; } // Query sessions with permissions check @@ -96,7 +97,8 @@ const sessionCache = cache(async () => { ); const adminApiKeys = await db.getApiKeysForUser(userId); - return { hostSessions, workspacesWithSessions, hasApiKeys: adminApiKeys.length > 0 }; + const harmonicaMd = await db.getUserHarmonicaMd(userId); + return { hostSessions, workspacesWithSessions, hasApiKeys: adminApiKeys.length > 0, hasHarmonicaMd: !!harmonicaMd }; } const hostSessionIds = userResources .filter((r) => r.resource_type === 'SESSION') @@ -146,10 +148,11 @@ const sessionCache = cache(async () => { apiKeys = [{ id: '', key_hash: '', key_prefix: keyPrefix, name: 'Default', user_id: userId } as any]; } - return { hostSessions, workspacesWithSessions, hasApiKeys: apiKeys.length > 0 }; + const harmonicaMd = await db.getUserHarmonicaMd(userId); + return { hostSessions, workspacesWithSessions, hasApiKeys: apiKeys.length > 0, hasHarmonicaMd: !!harmonicaMd }; } catch (error) { console.error('Failed to fetch host sessions: ', error); - return { hostSessions: [], workspacesWithSessions: [], hasApiKeys: false }; + return { hostSessions: [], workspacesWithSessions: [], hasApiKeys: false, hasHarmonicaMd: false }; } }); @@ -206,7 +209,7 @@ export default async function Dashboard({ }: { searchParams?: { page?: string }; }) { - const { hostSessions, workspacesWithSessions, hasApiKeys } = await sessionCache(); + const { hostSessions, workspacesWithSessions, hasApiKeys, hasHarmonicaMd } = await sessionCache(); if (!hostSessions) { return ; } @@ -227,16 +230,9 @@ export default async function Dashboard({ {/* Right column */}
-
- - - - -
- +
{/* Main dashboard content */} diff --git a/src/app/api/llamaUtils.ts b/src/app/api/llamaUtils.ts index 023f958..eb9d44b 100644 --- a/src/app/api/llamaUtils.ts +++ b/src/app/api/llamaUtils.ts @@ -9,6 +9,8 @@ import { getHostSessionById, updateUserSession, increaseSessionsCount, + getPermissions, + getUserHarmonicaMd, } from '@/lib/db'; import { initializeCrossPollination } from '@/lib/crossPollination'; import { getLLM } from '@/lib/modelConfig'; @@ -196,9 +198,25 @@ export async function handleGenerateAnswer( const basicFacilitationPrompt = await getPromptInstructions( 'BASIC_FACILITATION_PROMPT', ); + + // Fetch the session owner's HARMONICA.md for organizational context + let harmonicaMdBlock = ''; + try { + const perms = await getPermissions(messageData.sessionId, 'SESSION'); + const owner = perms.find(p => p.role === 'owner'); + if (owner?.user_id) { + const md = await getUserHarmonicaMd(owner.user_id); + if (md) { + harmonicaMdBlock = `Organizational Context (HARMONICA.md):\n${md}\n\n`; + } + } + } catch (e) { + console.warn('Failed to fetch HARMONICA.md for session:', e); + } + // Format context data const sessionContext = ` -System Instructions: +${harmonicaMdBlock}System Instructions: ${sessionData?.prompt || basicFacilitationPrompt} Session Information: diff --git a/src/app/api/onboarding/chat/route.ts b/src/app/api/onboarding/chat/route.ts new file mode 100644 index 0000000..fdb6dfa --- /dev/null +++ b/src/app/api/onboarding/chat/route.ts @@ -0,0 +1,93 @@ +import { NextResponse } from 'next/server'; +import { getSession } from '@auth0/nextjs-auth0'; +import { getLLM } from '@/lib/modelConfig'; +import { ChatMessage } from 'llamaindex'; + +export const maxDuration = 120; + +const ONBOARDING_SYSTEM_PROMPT = `You are Harmonica's onboarding assistant. Your job is to have a warm, conversational chat with a new user to learn about their team and goals, then generate a structured HARMONICA.md document that will give context to the AI facilitator in all their future sessions. + +CONVERSATION STRUCTURE: +Ask 3-4 focused questions, ONE at a time. Wait for the user's response before asking the next question. + +1. Start by warmly welcoming them and asking about their team or organization — who are they, what do they do? +2. Ask what they're hoping to achieve with Harmonica — what kind of discussions or decisions do they need help with? +3. Ask about the people who will participate in their sessions — roles, expertise, any group dynamics to be aware of. +4. Ask about their preferences for how the AI should facilitate — tone, structure, what good outcomes look like. + +STYLE: +- Be warm, concise, and genuinely curious +- Use natural follow-up questions based on their answers +- Keep each message short (2-3 sentences max + your question) +- Don't ask multiple questions at once + +GENERATING THE DOCUMENT: +After 3-4 exchanges (when you have enough context), tell the user you'll generate their HARMONICA.md and output it wrapped in tags. Fill in all 8 sections based on what they shared. For sections where you have no information, write a helpful placeholder like "Not yet specified — you can add details here later." + + +# HARMONICA.md + +## About +[Who is this group/org? Brief description] + +## Goals & Strategy +[What they're working towards] + +## Participants +[Who typically participates in sessions] + +## Vocabulary +[Domain-specific terminology, acronyms, jargon] + +## Prior Decisions +[Context about existing decisions or settled questions] + +## Facilitation Preferences +[How the AI should facilitate — tone, structure, style] + +## Constraints +[Decision processes, limits, regulatory requirements] + +## Success Patterns +[What good session outcomes look like for this group] + + +After outputting the document, tell the user they can review and edit each section before saving.`; + +export async function POST(req: Request) { + const session = await getSession(); + if (!session?.user?.sub) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { messages } = await req.json(); + + if (!messages || !Array.isArray(messages)) { + return NextResponse.json({ error: 'Messages array required' }, { status: 400 }); + } + + try { + const llm = getLLM('MAIN', 0.5, 4096); + const formattedMessages: ChatMessage[] = [ + { role: 'system', content: ONBOARDING_SYSTEM_PROMPT }, + ...messages.map((m: { role: string; content: string }) => ({ + role: m.role as 'user' | 'assistant', + content: m.content, + })), + ]; + + const response = await llm.chat({ + messages: formattedMessages, + distinctId: session.user.sub, + operation: 'onboarding_chat', + }); + + return NextResponse.json({ response }); + } catch (error) { + console.error('Onboarding chat error:', error); + return NextResponse.json( + { error: 'Failed to generate response' }, + { status: 500 }, + ); + } +} diff --git a/src/app/settings/HarmonicaMdTab.tsx b/src/app/settings/HarmonicaMdTab.tsx new file mode 100644 index 0000000..a9ca689 --- /dev/null +++ b/src/app/settings/HarmonicaMdTab.tsx @@ -0,0 +1,322 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { Button } from '@/components/ui/button'; +import { + Card, + CardContent, + CardDescription, + CardHeader, + CardTitle, +} from '@/components/ui/card'; +import { Textarea } from '@/components/ui/textarea'; +import { LoaderCircle, Check, FileText, Sparkles, ChevronDown, ChevronRight } from 'lucide-react'; +import { fetchHarmonicaMd, saveHarmonicaMd } from './actions'; +import { + Dialog, + DialogContent, +} from '@/components/ui/dialog'; +import OnboardingChat from '@/components/OnboardingChat'; + +const CHAR_LIMIT = 6000; + +const SECTIONS = [ + { + key: 'about', + title: 'About', + description: 'Who is your team, organization, or community?', + placeholder: 'e.g., We are the Product team at Acme Corp (12 people). We build a B2B SaaS tool for supply chain management.', + }, + { + key: 'goals', + title: 'Goals & Strategy', + description: 'What are you working towards?', + placeholder: 'e.g., Q1 priorities: reduce churn by 15%, launch self-serve onboarding, hire 2 senior engineers.', + }, + { + key: 'participants', + title: 'Participants', + description: 'Who typically participates in your sessions?', + placeholder: 'e.g., Sarah (CEO, strategic vision), Mike (CTO, technical feasibility), Priya (Head of Product, customer insights).', + }, + { + key: 'vocabulary', + title: 'Vocabulary', + description: 'Domain-specific terminology, acronyms, and jargon.', + placeholder: 'e.g., ARR = Annual Recurring Revenue. "The monolith" = our legacy backend. NPS = Net Promoter Score.', + }, + { + key: 'prior_decisions', + title: 'Prior Decisions', + description: 'What has already been decided or explored?', + placeholder: 'e.g., We decided to use React Native for mobile (Q4 2025). The pricing restructure is final.', + }, + { + key: 'facilitation', + title: 'Facilitation Preferences', + description: 'How should the AI facilitator behave with your group?', + placeholder: 'e.g., Be direct, push back on vague answers. We prefer structured outputs (bullet points, action items).', + }, + { + key: 'constraints', + title: 'Constraints', + description: 'Decision-making processes, approvals, or regulatory requirements.', + placeholder: 'e.g., Any spend over $5K needs board approval. We\'re SOC2 compliant.', + }, + { + key: 'success', + title: 'Success Patterns', + description: 'What does a good session outcome look like?', + placeholder: 'e.g., Good sessions end with clear next steps, explicit disagreements surfaced, and a shareable summary.', + }, +]; + +type SectionValues = Record; + +function parseSections(markdown: string): SectionValues { + const values: SectionValues = {}; + const sectionMap: Record = { + 'about': 'about', + 'goals & strategy': 'goals', + 'goals': 'goals', + 'participants': 'participants', + 'vocabulary': 'vocabulary', + 'prior decisions': 'prior_decisions', + 'prior decisions & context': 'prior_decisions', + 'facilitation preferences': 'facilitation', + 'constraints': 'constraints', + 'success patterns': 'success', + }; + + const lines = markdown.split('\n'); + let currentKey = ''; + + for (const line of lines) { + const headerMatch = line.match(/^##\s+(.+)$/); + if (headerMatch) { + const title = headerMatch[1].trim().toLowerCase(); + currentKey = sectionMap[title] || ''; + continue; + } + if (currentKey && line.trim() !== '# HARMONICA.md') { + values[currentKey] = ((values[currentKey] || '') + '\n' + line).trim(); + } + } + + return values; +} + +function assembleSections(values: SectionValues): string { + const parts = ['# HARMONICA.md', '']; + for (const section of SECTIONS) { + const content = values[section.key]?.trim(); + if (content) { + parts.push(`## ${section.title}`); + parts.push(content); + parts.push(''); + } + } + return parts.join('\n').trim(); +} + +export default function HarmonicaMdTab() { + const [sections, setSections] = useState({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [saved, setSaved] = useState(false); + const [hasContent, setHasContent] = useState(false); + const [showRegenerate, setShowRegenerate] = useState(false); + const [expanded, setExpanded] = useState>({}); + const [error, setError] = useState(null); + + const totalChars = assembleSections(sections).length; + const isOverLimit = totalChars > CHAR_LIMIT; + + useEffect(() => { + loadContent(); + }, []); + + const loadContent = async () => { + setLoading(true); + try { + const content = await fetchHarmonicaMd(); + if (content) { + setSections(parseSections(content)); + setHasContent(true); + // Expand sections that have content + const exp: Record = {}; + const parsed = parseSections(content); + for (const s of SECTIONS) { + if (parsed[s.key]?.trim()) exp[s.key] = true; + } + setExpanded(exp); + } else { + // Expand first 3 sections for new users + setExpanded({ about: true, goals: true, participants: true }); + } + } catch (e) { + console.error(e); + } finally { + setLoading(false); + } + }; + + const handleSave = useCallback(async () => { + const content = assembleSections(sections); + if (isOverLimit) return; + + setSaving(true); + setError(null); + try { + const result = await saveHarmonicaMd(content); + if (result.success) { + setSaved(true); + setHasContent(true); + setTimeout(() => setSaved(false), 2000); + } else { + setError(result.message || 'Failed to save'); + } + } catch (e) { + setError('Failed to save'); + console.error(e); + } finally { + setSaving(false); + } + }, [sections, isOverLimit]); + + const handleRegenerateComplete = () => { + setShowRegenerate(false); + loadContent(); + }; + + const toggleSection = (key: string) => { + setExpanded(prev => ({ ...prev, [key]: !prev[key] })); + }; + + if (loading) { + return ( + + + + + + ); + } + + return ( + <> +
+ + +
+
+ + + HARMONICA.md + + + Persistent context about your organization that the AI facilitator uses in every session. + Like CLAUDE.md for code — but for group facilitation. + +
+ +
+
+ + {SECTIONS.map((section) => { + const isExpanded = expanded[section.key]; + return ( +
+ + {isExpanded && ( +
+

{section.description}

+