From de9798521f7b4453d22b0528eb33534cd389b344 Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Tue, 11 Aug 2026 09:24:53 -0400 Subject: [PATCH 01/16] fix(notes): make mermaid subgraph labels contrast with their fill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LLM-generated diagrams often give subgraphs (clusters) an explicit light pastel fill while mermaid's label colour follows the theme — light text in dark mode becomes invisible on a light fill. After render, read each cluster's actual fill luminance and pin the label to a contrasting dark/light colour, in both the inline and fullscreen views. --- changelogs/v2.6.12.md | 5 +++ src/components/notes/MermaidDiagram.test.ts | 35 ++++++++++++++++ src/components/notes/MermaidDiagram.tsx | 44 +++++++++++++++++++++ 3 files changed, 84 insertions(+) create mode 100644 changelogs/v2.6.12.md create mode 100644 src/components/notes/MermaidDiagram.test.ts diff --git a/changelogs/v2.6.12.md b/changelogs/v2.6.12.md new file mode 100644 index 000000000..ee618cdc9 --- /dev/null +++ b/changelogs/v2.6.12.md @@ -0,0 +1,5 @@ +## What's new + +### Fixed + +- **Mermaid subgraph labels are readable again in dark mode.** Subgraph (cluster) titles now get their colour computed from the actual fill the diagram uses — so when an LLM-generated chart paints a light pastel background, the label flips to dark text instead of inheriting the theme's light text and becoming invisible. diff --git a/src/components/notes/MermaidDiagram.test.ts b/src/components/notes/MermaidDiagram.test.ts new file mode 100644 index 000000000..a4f1a57d9 --- /dev/null +++ b/src/components/notes/MermaidDiagram.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect } from "vitest"; +import { parseColor, colorLuminance } from "./MermaidDiagram"; + +describe("parseColor", () => { + it("parses 6-digit hex", () => { + expect(parseColor("#1a1917")).toEqual([26, 25, 23]); + expect(parseColor("#e8e4dc")).toEqual([232, 228, 220]); + }); + + it("parses rgb()", () => { + expect(parseColor("rgb(240, 238, 235)")).toEqual([240, 238, 235]); + }); + + it("rejects unknown formats", () => { + expect(parseColor("")).toBeNull(); + expect(parseColor("none")).toBeNull(); + expect(parseColor("url(#grad)")).toBeNull(); + expect(parseColor("#fff")).toBeNull(); + }); +}); + +describe("colorLuminance", () => { + it("returns a low value for dark colours", () => { + expect(colorLuminance("#1a1917")).not.toBeNull(); + expect(colorLuminance("#1a1917")!).toBeLessThan(0.1); + }); + + it("returns a high value for light colours", () => { + expect(colorLuminance("#f0eeeb")!).toBeGreaterThan(0.8); + }); + + it("returns null for unparseable input", () => { + expect(colorLuminance("none")).toBeNull(); + }); +}); diff --git a/src/components/notes/MermaidDiagram.tsx b/src/components/notes/MermaidDiagram.tsx index a7a140c5a..25a635b72 100644 --- a/src/components/notes/MermaidDiagram.tsx +++ b/src/components/notes/MermaidDiagram.tsx @@ -61,6 +61,48 @@ async function getMermaid(id: string) { return { mermaid, id }; } +// ── Subgraph label contrast ─────────────────────────────────────────────────── + +// Subgraphs (clusters) can carry an explicit fill chosen by the diagram author +// (LLM-generated charts often pick light pastels). Mermaid's label colour +// follows the theme — light text in dark mode — so an explicitly-light fill +// becomes unreadable. Fix by computing the actual fill luminance per cluster +// and pinning the label to a contrasting dark/light fill. +export const parseColor = (input: string): [number, number, number] | null => { + const s = (input ?? "").trim(); + const hex = s.match(/^#([0-9a-f]{6})$/i); + if (hex) { + const n = parseInt(hex[1], 16); + return [(n >> 16) & 255, (n >> 8) & 255, n & 255]; + } + const rgb = s.match(/^rgb\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)\s*\)$/); + if (rgb) return [parseInt(rgb[1], 10), parseInt(rgb[2], 10), parseInt(rgb[3], 10)]; + return null; +}; + +export const colorLuminance = (input: string): number | null => { + const rgb = parseColor(input); + if (!rgb) return null; + const [r, g, b] = rgb.map((c) => { + const s = c / 255; + return s <= 0.03928 ? s / 12.92 : Math.pow((s + 0.055) / 1.055, 2.4); + }); + return 0.2126 * r + 0.7152 * g + 0.0722 * b; +}; + +function enforceClusterLabelContrast(container: HTMLElement) { + for (const cluster of container.querySelectorAll("g.cluster")) { + const rect = cluster.querySelector("rect") ?? (cluster.firstElementChild as SVGGraphicsElement | null); + const label = cluster.querySelector(".cluster-label text, text.cluster-label"); + if (!rect || !label) continue; + const attrFill = rect.getAttribute("fill"); + const fill = attrFill && attrFill !== "none" ? attrFill : getComputedStyle(rect).fill; + const luminance = colorLuminance(fill); + if (luminance === null) continue; + label.setAttribute("fill", luminance > 0.5 ? "#1a1917" : "#e8e4dc"); + } +} + // ── Full-screen modal ───────────────────────────────────────────────────────── function DiagramModal({ chart, onClose }: { chart: string; onClose: () => void }) { @@ -78,6 +120,7 @@ function DiagramModal({ chart, onClose }: { chart: string; onClose: () => void } const { svg } = await mermaid.render(modalId, chart.trim()); if (!cancelled && containerRef.current) { containerRef.current.innerHTML = svg; + enforceClusterLabelContrast(containerRef.current); // Remove fixed width/height so SVG scales to fill the modal const svgEl = containerRef.current.querySelector("svg"); if (svgEl) { @@ -131,6 +174,7 @@ export function MermaidDiagram({ chart }: Props) { const { svg } = await mermaid.render(`mermaid-${id}`, chart.trim()); if (!cancelled && containerRef.current) { containerRef.current.innerHTML = svg; + enforceClusterLabelContrast(containerRef.current); setError(null); } } catch (e) { From a1b7392258c67f1c1b48af370e897f52f818606f Mon Sep 17 00:00:00 2001 From: Gerard Slee Date: Tue, 11 Aug 2026 10:23:06 -0400 Subject: [PATCH 02/16] =?UTF-8?q?feat(chat):=20community=20personalities?= =?UTF-8?q?=20=E2=80=94=20style-layer=20system=20prompt=20+=20picker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A personality is a set of behavioral rules appended to the chat system prompt as a delimited style layer (never a replacement identity). Pick one next to the model selector in the chat input; Default adds nothing. - personalities.json manifest fetch/cache/IPC (mirrors providers) - Zod schema rejects 'You are …' openings + thin one-liner prompts - withPersonality() injection in the main-process chat loop (covers the default prompt, the graph-view override, and token-usage recompute) - aiConfig.installedPersonalities + personalityId (persisted via localStorage + config-cache whitelist), actions for install/remove/ create/select - PersonalityPicker in ChatInputArea, BrowsePersonalitiesModal, and a Chat personality section in Settings → AI & Chat - changelog v2.6.12 + What's New entry; tests for schema, fetch, store, config-cache, and injection --- changelogs/v2.6.12.md | 6 + electron/ipc/chat.ts | 6 +- electron/ipc/community-registry-handlers.ts | 5 + electron/lib/community-registry.test.ts | 94 +++++- electron/lib/community-registry.ts | 41 ++- electron/lib/config-cache.test.ts | 28 ++ electron/lib/config-cache.ts | 22 ++ electron/lib/tools.ts | 21 ++ electron/lib/with-personality.test.ts | 18 ++ electron/preload.ts | 13 + scripts/features.config.js | 13 + shared/chat/registry-schema.ts | 69 +++++ .../chat/BrowsePersonalitiesModal.tsx | 287 ++++++++++++++++++ src/components/chat/ChatInputArea.tsx | 2 + src/components/chat/chat-panel/index.tsx | 7 + src/components/settings/AISettings.tsx | 140 ++++++++- src/components/ui/personality-picker.tsx | 268 ++++++++++++++++ src/hooks/useChatStream.ts | 3 + src/store/slices/ui.test.ts | 80 +++++ src/store/slices/ui.ts | 134 ++++++++ src/types/index.ts | 30 ++ 21 files changed, 1280 insertions(+), 7 deletions(-) create mode 100644 electron/lib/with-personality.test.ts create mode 100644 src/components/chat/BrowsePersonalitiesModal.tsx create mode 100644 src/components/ui/personality-picker.tsx diff --git a/changelogs/v2.6.12.md b/changelogs/v2.6.12.md index ee618cdc9..a0d774b0e 100644 --- a/changelogs/v2.6.12.md +++ b/changelogs/v2.6.12.md @@ -1,5 +1,11 @@ ## What's new +### Added + +- **Chat personalities — shape how the AI talks to you.** Pick a personality next to the model selector in the chat input to layer behavioral rules onto the system prompt: *Caveman* (short, plain words), *Grill Me* (stress-tests your plan with calibrated questions), *Junior to Senior* (adversarial senior review), *Last 20%* (finishes the experiential layer), *Ponytail* (the lazy senior dev — write only what's needed), and more. The full prompt is shown before you install, so you always know exactly what's being added. + - **Default is unchanged** — with no personality selected the assistant behaves exactly as before; personalities are an additive style layer, never a replacement identity. + - **Browse + install** from the community catalog in Settings → AI & Chat → Chat personality, or create your own behavioral rules in a couple of clicks. + ### Fixed - **Mermaid subgraph labels are readable again in dark mode.** Subgraph (cluster) titles now get their colour computed from the actual fill the diagram uses — so when an LLM-generated chart paints a light pastel background, the label flips to dark text instead of inheriting the theme's light text and becoming invisible. diff --git a/electron/ipc/chat.ts b/electron/ipc/chat.ts index 633d5d02c..21692eb02 100644 --- a/electron/ipc/chat.ts +++ b/electron/ipc/chat.ts @@ -12,7 +12,7 @@ import { registerIpcHandle, registerIpcOn, broadcastEvent } from "./registry"; import { broadcastToChat } from "../chat-popout"; import type Database from "better-sqlite3"; import { isLocalEndpoint, normaliseBaseUrl, type OpenAIMessage, calculatePromptBreakdown, scaleBreakdown, type TokenBreakdown, isSendableMessage } from "../lib/llm"; -import { TOOLS, buildSystemPrompt, type ChatRequest } from "../lib/tools"; +import { TOOLS, buildSystemPrompt, withPersonality, type ChatRequest } from "../lib/tools"; import { getExternalToolDefs } from "../lib/external-tools"; import { getCachedConfig, cacheLlmConnection } from "../lib/config-cache"; import { runToolLoop } from "../lib/chat-loop"; @@ -160,7 +160,7 @@ export function registerChatHandler(db: Database.Database, workspacePath: string const messages: OpenAIMessage[] = [ { role: resolveSystemRole({ isReasoningModel: req.config?.isReasoningModel, baseUrl, provider, modelId: model }), - content: buildSystemPrompt(req), + content: withPersonality(buildSystemPrompt(req), req.personality), }, ...(req.history ?? []) // Drop assistant turns that carry neither content nor tool_calls — a @@ -236,7 +236,7 @@ export function registerChatHandler(db: Database.Database, workspacePath: string costUsd = (costUsd ?? 0) + cost; } try { - const rawBreakdown = calculatePromptBreakdown(buildSystemPrompt(req), messages, allTools); + const rawBreakdown = calculatePromptBreakdown(withPersonality(buildSystemPrompt(req), req.personality), messages, allTools); lastBreakdown = scaleBreakdown(rawBreakdown, promptTokens); } catch (err) { console.error("[chat] failed to calculate breakdown:", err); diff --git a/electron/ipc/community-registry-handlers.ts b/electron/ipc/community-registry-handlers.ts index 7d478e682..5fad45432 100644 --- a/electron/ipc/community-registry-handlers.ts +++ b/electron/ipc/community-registry-handlers.ts @@ -17,6 +17,8 @@ import { refreshProvidersManifest, fetchAutomationsManifest, refreshAutomationsManifest, + fetchPersonalitiesManifest, + refreshPersonalitiesManifest, } from "../lib/community-registry"; export function registerCommunityRegistryHandlers(): void { @@ -29,4 +31,7 @@ export function registerCommunityRegistryHandlers(): void { // Automations live in a SEPARATE manifest (automations.json), same rationale. registerIpcHandle("registry:fetchAutomations", () => handle(() => fetchAutomationsManifest())); registerIpcHandle("registry:refreshAutomations", () => handle(() => refreshAutomationsManifest())); + // Personalities live in a SEPARATE manifest (personalities.json), same rationale. + registerIpcHandle("registry:fetchPersonalities", () => handle(() => fetchPersonalitiesManifest())); + registerIpcHandle("registry:refreshPersonalities", () => handle(() => refreshPersonalitiesManifest())); } diff --git a/electron/lib/community-registry.test.ts b/electron/lib/community-registry.test.ts index 7a32a99a3..e2c8d3312 100644 --- a/electron/lib/community-registry.test.ts +++ b/electron/lib/community-registry.test.ts @@ -18,7 +18,7 @@ vi.mock("../runtime/port-discovery", () => ({ })); // Import AFTER the mock is registered. -import { parseManifest, fetchManifest, refreshManifest, __test } from "./community-registry"; +import { parseManifest, parsePersonalitiesManifest, fetchManifest, fetchPersonalitiesManifest, refreshManifest, __test } from "./community-registry"; const VALID = { version: 1, @@ -162,6 +162,98 @@ describe("parseManifest", () => { }); }); +describe("parsePersonalitiesManifest", () => { + const VALID_PERSONALITIES = { + version: 1, + updatedAt: "2026-08-11T00:00:00Z", + personalities: [ + { + id: "caveman", + author: "cairn", + version: "1.0.0", + category: "Tone & Style", + tags: ["tone"], + blurb: "Short, plain words.", + brandColor: "#9ca3af", + homepage: "https://github.com/JuliusBrussee/caveman", + definition: { + name: "Caveman", + description: "Blunt, minimal answers.", + prompt: "Speak in short, simple sentences. Use plain words. Give the answer or result first.", + }, + }, + ], + }; + + it("accepts a valid personalities manifest", () => { + const m = parsePersonalitiesManifest(VALID_PERSONALITIES); + expect(m.personalities).toHaveLength(1); + expect(m.personalities[0].definition.name).toBe("Caveman"); + }); + + it("drops a personality whose prompt opens with a 'You are …' identity claim", () => { + const bad = structuredClone(VALID_PERSONALITIES) as { personalities: Array<{ definition: { prompt: string } }> }; + bad.personalities[0].definition.prompt = "You are Grug Assistant.\nSpeak short."; + const m = parsePersonalitiesManifest(bad); + expect(m.personalities).toHaveLength(0); + }); + + it("drops a personality whose prompt is a thin one-liner (<20 chars)", () => { + const bad = structuredClone(VALID_PERSONALITIES) as { personalities: Array<{ definition: { prompt: string } }> }; + bad.personalities[0].definition.prompt = "be concise."; + const m = parsePersonalitiesManifest(bad); + expect(m.personalities).toHaveLength(0); + }); + + it("drops a malformed entry instead of rejecting the whole manifest", () => { + const mixed = structuredClone(VALID_PERSONALITIES) as Record; + const list = mixed.personalities as Array>; + list.push({ id: "bad", author: "x", version: "1.0.0", tags: [], blurb: "bad", definition: {} }); + const m = parsePersonalitiesManifest(mixed); + expect(m.personalities).toHaveLength(1); + }); +}); + +describe("fetchPersonalitiesManifest", () => { + const VALID = { + version: 1, + updatedAt: "2026-08-11T00:00:00Z", + personalities: [ + { + id: "grill-me", + author: "cairn", + version: "1.0.0", + category: "Critique & Review", + tags: ["stress-test"], + blurb: "Stress-tests your plan.", + definition: { + name: "Grill Me", + prompt: "Pressure-test plans with calibrated questions. Ask one question at a time. Give a recommended answer.", + }, + }, + ], + }; + + it("fetches from network, validates, and writes its own cache file", async () => { + vi.stubGlobal("fetch", vi.fn(async () => fetchResponse({ status: 200, json: VALID }))); + const res = await fetchPersonalitiesManifest({ force: true }); + expect(res.fromCache).toBe(false); + expect(res.manifest.personalities[0].definition.name).toBe("Grill Me"); + expect(__test.readPersonalitiesCache()?.manifest.personalities).toHaveLength(1); + }); + + it("fails soft to the cache when the network fails", async () => { + vi.stubGlobal("fetch", vi.fn(async () => fetchResponse({ status: 200, json: VALID }))); + await fetchPersonalitiesManifest({ force: true }); + + vi.stubGlobal("fetch", vi.fn(async () => { throw new Error("offline"); })); + const res = await fetchPersonalitiesManifest({ force: true }); + expect(res.fromCache).toBe(true); + expect(res.error).toBe("offline"); + expect(res.manifest.personalities).toHaveLength(1); + }); +}); + describe("fetchManifest", () => { it("fetches from network, validates, and writes the cache (200)", async () => { vi.stubGlobal( diff --git a/electron/lib/community-registry.ts b/electron/lib/community-registry.ts index 602f83d1d..6f4747520 100644 --- a/electron/lib/community-registry.ts +++ b/electron/lib/community-registry.ts @@ -22,9 +22,11 @@ import { parseManifest, parseProvidersManifest, parseAutomationsManifest, + parsePersonalitiesManifest, type CommunityManifest, type ProvidersManifest, type AutomationsManifest, + type PersonalitiesManifest, } from "../../shared/chat/registry-schema"; // Manifest TYPES + Zod validation now live in shared/chat/registry-schema.ts so @@ -34,13 +36,15 @@ export type { CommunityManifest, ProvidersManifest, AutomationsManifest, + PersonalitiesManifest, RegistryMcpEntry, RegistryServiceEntry, RegistryProviderEntry, RegistryAutomationEntry, + RegistryPersonalityEntry, RegistryEntryMeta, } from "../../shared/chat/registry-schema"; -export { parseManifest, parseProvidersManifest, parseAutomationsManifest } from "../../shared/chat/registry-schema"; +export { parseManifest, parseProvidersManifest, parseAutomationsManifest, parsePersonalitiesManifest } from "../../shared/chat/registry-schema"; export interface RegistryFetchResult { manifest: CommunityManifest; @@ -63,15 +67,25 @@ export interface AutomationsFetchResult { error?: string; } +export interface PersonalitiesFetchResult { + manifest: PersonalitiesManifest; + fromCache: boolean; + cachedAt?: string; + error?: string; +} + const MANIFEST_URL = "https://raw.githubusercontent.com/ddutchie/cairn-community/main/manifest.json"; const PROVIDERS_URL = "https://raw.githubusercontent.com/ddutchie/cairn-community/main/providers.json"; const AUTOMATIONS_URL = "https://raw.githubusercontent.com/ddutchie/cairn-community/main/automations.json"; +const PERSONALITIES_URL = + "https://raw.githubusercontent.com/ddutchie/cairn-community/main/personalities.json"; const CACHE_FILE = "community-registry.json"; const PROVIDERS_CACHE_FILE = "community-providers.json"; const AUTOMATIONS_CACHE_FILE = "community-automations.json"; +const PERSONALITIES_CACHE_FILE = "community-personalities.json"; const FETCH_TIMEOUT_MS = 10_000; // ── generic cache + fetch core (shared by both manifests) ─────────────────── @@ -280,14 +294,35 @@ export function refreshAutomationsManifest(): Promise { return fetchAutomationsManifest({ force: true }); } +// ── personalities manifest ─────────────────────────────────────────────────── + +const PERSONALITIES_SPEC: FetchSpec = { + url: PERSONALITIES_URL, + file: PERSONALITIES_CACHE_FILE, + parse: parsePersonalitiesManifest, + empty: { version: 1, updatedAt: "", personalities: [] }, +}; + +/** Fetch the community PERSONALITIES manifest (cache-first; see fetchManifest). */ +export function fetchPersonalitiesManifest(opts?: { force?: boolean }): Promise { + return fetchGeneric(PERSONALITIES_SPEC, opts); +} + +/** Force a network refresh of the personalities manifest. */ +export function refreshPersonalitiesManifest(): Promise { + return fetchPersonalitiesManifest({ force: true }); +} + /** Exposed for tests. */ export const __test = { MANIFEST_URL, PROVIDERS_URL, AUTOMATIONS_URL, + PERSONALITIES_URL, CACHE_FILE, PROVIDERS_CACHE_FILE, AUTOMATIONS_CACHE_FILE, + PERSONALITIES_CACHE_FILE, cachePath: () => cacheFilePath(CACHE_FILE), readCache: () => readCacheFile(CACHE_FILE, parseManifest), writeCache: (env: CacheEnvelope) => writeCacheFile(CACHE_FILE, env), @@ -299,4 +334,8 @@ export const __test = { readAutomationsCache: () => readCacheFile(AUTOMATIONS_CACHE_FILE, parseAutomationsManifest), writeAutomationsCache: (env: CacheEnvelope) => writeCacheFile(AUTOMATIONS_CACHE_FILE, env), + personalitiesCachePath: () => cacheFilePath(PERSONALITIES_CACHE_FILE), + readPersonalitiesCache: () => readCacheFile(PERSONALITIES_CACHE_FILE, parsePersonalitiesManifest), + writePersonalitiesCache: (env: CacheEnvelope) => + writeCacheFile(PERSONALITIES_CACHE_FILE, env), }; diff --git a/electron/lib/config-cache.test.ts b/electron/lib/config-cache.test.ts index cbb78a7ad..00a35270e 100644 --- a/electron/lib/config-cache.test.ts +++ b/electron/lib/config-cache.test.ts @@ -92,6 +92,34 @@ describe("config-cache AI settings round-trip", () => { expect(ai?.maxOutputAuto).toBe(true); expect(ai?.maxOutputTokens).toBe(8192); }); + + it("persists installedPersonalities + the active personalityId", async () => { + const { saveCachedConfig, getCachedConfig } = await import("./config-cache"); + const installedPersonalities = [ + { + id: "p1", + name: "Grill Me", + description: "Calibrated grilling.", + prompt: "Pressure-test plans with calibrated questions.", + source: "community" as const, + communityId: "grill-me", + version: "1.0.0", + author: "cairn", + brandColor: "#f43f5e", + homepage: "https://github.com/JuliusBrussee/skills", + }, + { id: "p2", name: "Mine", prompt: "Be brief.", source: "custom" as const }, + ]; + saveCachedConfig("ai", { installedPersonalities, personalityId: "p1" }); + const ai = getCachedConfig().aiConfig; + expect(ai?.installedPersonalities).toHaveLength(2); + expect(ai?.installedPersonalities?.[0].communityId).toBe("grill-me"); + expect(ai?.personalityId).toBe("p1"); + // A later connection-only save must not clobber the personality fields. + saveCachedConfig("ai", { model: "gpt-4o" }); + expect(getCachedConfig().aiConfig?.personalityId).toBe("p1"); + expect(getCachedConfig().aiConfig?.installedPersonalities).toHaveLength(2); + }); }); describe("config-cache without Electron", () => { diff --git a/electron/lib/config-cache.ts b/electron/lib/config-cache.ts index 0fd9d47e3..3cac665ed 100644 --- a/electron/lib/config-cache.ts +++ b/electron/lib/config-cache.ts @@ -35,6 +35,22 @@ export interface CachedConfig { // id of the active one. Persisted so the switcher survives restarts. savedProviders?: Array<{ id: string; name: string; baseUrl: string; apiKey: string; model: string }>; activeProviderId?: string; + // Installed chat personalities (community + custom) and the active one. + // Persisted so the picker + selection survive restarts; the personality + // text is plain (no secrets), so it is cached verbatim. + installedPersonalities?: Array<{ + id: string; + name: string; + description?: string; + prompt: string; + source: "community" | "custom"; + communityId?: string; + version?: string; + author?: string; + brandColor?: string; + homepage?: string; + }>; + personalityId?: string; }; agentConfig?: { baseUrl?: string; @@ -153,6 +169,12 @@ export function saveCachedConfig(type: "ai" | "agent" | "embeddings" | "theme" | ) : current.aiConfig?.savedProviders, activeProviderId: typeof configRecord.activeProviderId === "string" ? configRecord.activeProviderId : current.aiConfig?.activeProviderId, + // Installed personalities + active selection. The prompt text is plain + // (never a secret), so it is stored verbatim like savedProviders. + installedPersonalities: Array.isArray((config as { installedPersonalities?: unknown }).installedPersonalities) + ? (config as { installedPersonalities: NonNullable["installedPersonalities"] }).installedPersonalities + : current.aiConfig?.installedPersonalities, + personalityId: typeof configRecord.personalityId === "string" ? configRecord.personalityId : current.aiConfig?.personalityId, }; } else if (type === "agent" && configRecord) { current.agentConfig = { diff --git a/electron/lib/tools.ts b/electron/lib/tools.ts index 902a1286b..5c1b61245 100644 --- a/electron/lib/tools.ts +++ b/electron/lib/tools.ts @@ -144,6 +144,12 @@ export interface ChatRequest { history?: ChatHistoryEntry[]; config?: { baseUrl?: string; model?: string; apiKey?: string; maxSteps?: number; temperature?: number; maxTokens?: number; isReasoningModel?: boolean }; systemPrompt?: string; + /** + * Active chat personality for this turn — a style layer appended verbatim + * after the system prompt. `name` is the display label; `prompt` holds the + * behavioral rules. Absent = no personality (default Cairn behavior). + */ + personality?: { name: string; prompt: string }; /** Attachments on the current user message (base64 data URLs; kind="pdf" * becomes an Anthropic-style `document` part, images become `image_url`). */ images?: Array<{ name: string; dataUrl: string; kind?: "image" | "pdf" }>; @@ -173,6 +179,21 @@ Use the provided tools to read and modify the user's workspace. Choose the tool Tone: calm, focused, like a thoughtful co-worker.`; } +/** + * Append the active chat personality to a built system prompt as a clearly + * delimited style LAYER. A personality is never a replacement identity — the + * base "You are the Cairn AI assistant" prompt stays intact and the behavioral + * rules are added under their own header so the model treats them as session + * style guidance rather than a conflicting persona. + */ +export function withPersonality( + systemPrompt: string, + personality?: { name: string; prompt: string }, +): string { + if (!personality || !personality.name || !personality.prompt) return systemPrompt; + return `${systemPrompt}\n\n## Personality: ${personality.name}\n${personality.prompt}`; +} + /** * System prompt for the interactive PRD generation micro-chat. * Scoped to a single project. The agent gathers context, asks focused diff --git a/electron/lib/with-personality.test.ts b/electron/lib/with-personality.test.ts new file mode 100644 index 000000000..68a5736d4 --- /dev/null +++ b/electron/lib/with-personality.test.ts @@ -0,0 +1,18 @@ +import { describe, it, expect } from "vitest"; +import { withPersonality } from "./tools"; + +describe("withPersonality", () => { + const base = "You are the Cairn AI assistant — an intelligent helper."; + + it("returns the prompt unchanged when no personality is given", () => { + expect(withPersonality(base, undefined)).toBe(base); + expect(withPersonality(base, { name: "", prompt: "" })).toBe(base); + }); + + it("appends the personality as a delimited style layer", () => { + const out = withPersonality(base, { name: "Caveman", prompt: "Speak short." }); + expect(out).toContain("## Personality: Caveman"); + expect(out).toContain("Speak short."); + expect(out.indexOf(base)).toBe(0); // the base Cairn identity stays first + }); +}); diff --git a/electron/preload.ts b/electron/preload.ts index b8cb7fccc..456ff52a8 100644 --- a/electron/preload.ts +++ b/electron/preload.ts @@ -94,6 +94,15 @@ interface AutomationsManifest { interface AutomationsFetchResult { manifest: AutomationsManifest; fromCache: boolean; cachedAt?: string; error?: string; } +interface RegistryPersonalityEntry extends RegistryEntryMeta { + definition: { name: string; description?: string; prompt: string }; +} +interface PersonalitiesManifest { + version: number; updatedAt: string; personalities: RegistryPersonalityEntry[]; +} +interface PersonalitiesFetchResult { + manifest: PersonalitiesManifest; fromCache: boolean; cachedAt?: string; error?: string; +} // ── Inline types for the codebase index / Architecture tab ────────────────── interface CodebaseSymbol { id: string; file_id: string; name: string; kind: string; line: number; @@ -856,6 +865,10 @@ const api = { fetchAutomations: () => invoke("registry:fetchAutomations"), /** Force a network refresh of the automations manifest. */ refreshAutomations: () => invoke("registry:refreshAutomations"), + /** Community personalities (separate personalities.json manifest). Cache-first. */ + fetchPersonalities: () => invoke("registry:fetchPersonalities"), + /** Force a network refresh of the personalities manifest. */ + refreshPersonalities: () => invoke("registry:refreshPersonalities"), }, // ── Git operations (Agent Git tab) ──────────── diff --git a/scripts/features.config.js b/scripts/features.config.js index f7f5faf19..4cc0e8beb 100644 --- a/scripts/features.config.js +++ b/scripts/features.config.js @@ -233,6 +233,19 @@ const FEATURES = [ "Persists Per Session: Todos are stored with the session and restored when you resume it, so long-running work survives restarts.", ], }, + { + id: "v2.6.12-chat-personalities", + version: "v2.6.12", + title: "Chat Personalities", + category: "Chat", + description: + "Pick a personality next to the model selector in the chat input to layer behavioral rules onto the system prompt. The catalog ships with Caveman, Grill Me, Junior to Senior, Last 20%, and Ponytail — all with full prompt transparency — plus a way to browse more from the community or write your own.", + highlights: [ + "Session Style Layer: A personality is appended to the system prompt as behavioral rules — Default keeps the exact current assistant with no layer.", + "Community Catalog: Browse and install ready-made personalities from cairn-community in Settings → AI & Chat → Chat personality; the full prompt is shown before you install.", + "Write Your Own: Create custom rules in the picker or Settings — e.g. 'Always talk in ASD-STE100 Simplified English'.", + ], + }, ]; module.exports = { FEATURES }; diff --git a/shared/chat/registry-schema.ts b/shared/chat/registry-schema.ts index aa5fe47d9..6788093a4 100644 --- a/shared/chat/registry-schema.ts +++ b/shared/chat/registry-schema.ts @@ -226,6 +226,35 @@ export interface ProvidersManifest { providers: RegistryProviderEntry[]; } +/** + * A community personality — a set of behavioral rules appended verbatim to + * Cairn's chat system prompt to shape the assistant's tone and style. Installed + * into `aiConfig.installedPersonalities` and picked next to the model selector + * in chat. Kept in a SEPARATE manifest (personalities.json) from the tools + * catalog so the two can evolve independently. + */ +export interface RegistryPersonalityEntry extends RegistryEntryMeta { + definition: { + /** Display name shown in the personality picker. */ + name: string; + /** One line shown in the browse card and picker list. */ + description?: string; + /** + * Behavioral rules appended to the chat system prompt. A style LAYER on the + * existing "You are the Cairn AI assistant" identity — must NOT open with a + * "You are …" identity claim (CI rejects those upstream). + */ + prompt: string; + }; +} + +/** The parsed cairn-community PERSONALITIES manifest (personalities.json). */ +export interface PersonalitiesManifest { + version: number; + updatedAt: string; + personalities: RegistryPersonalityEntry[]; +} + // ── validation (mirrors cairn-community/schema.json) ──────────────────────── const headers = z.record(z.string(), z.string()).optional(); @@ -466,3 +495,43 @@ export function parseProvidersManifest(raw: unknown): ProvidersManifest { }), } as ProvidersManifest; } + +// ── personalities manifest (personalities.json) ────────────────────────────── + +const personalityDefinition = z.object({ + name: z.string().min(1), + description: z.string().optional(), + // A personality is a STYLE LAYER on the existing Cairn assistant identity. An + // opening "You are …" claim would contradict the base system prompt, so it is + // rejected here (mirrors the cairn-community validator). Min 20 chars keeps + // thin one-liners that add nothing to the prompt out of the catalog. + prompt: z + .string() + .min(20) + .refine((p) => !/^you\s+are/i.test(p.trim()), { + message: "prompt must not start with a 'You are …' identity claim", + }), +}); + +const personalityEntry = z.object({ ...entryMeta, definition: personalityDefinition }).passthrough(); + +// Envelope-only validation; entries validated individually in +// parsePersonalitiesManifest so one bad entry can't blank the catalog. +const personalitiesManifestSchema = z.object({ + version: z.number(), + updatedAt: z.string(), + personalities: z.array(z.unknown()), +}); + +/** Parse + validate an unknown payload into a PersonalitiesManifest, or throw. */ +export function parsePersonalitiesManifest(raw: unknown): PersonalitiesManifest { + const m = personalitiesManifestSchema.parse(raw); + return { + version: m.version, + updatedAt: m.updatedAt, + personalities: m.personalities.flatMap((e) => { + const r = personalityEntry.safeParse(e); + return r.success ? [r.data] : []; + }), + } as PersonalitiesManifest; +} diff --git a/src/components/chat/BrowsePersonalitiesModal.tsx b/src/components/chat/BrowsePersonalitiesModal.tsx new file mode 100644 index 000000000..c63c8cc2e --- /dev/null +++ b/src/components/chat/BrowsePersonalitiesModal.tsx @@ -0,0 +1,287 @@ +"use client"; + +/** + * Browse Community Personalities — install ready-made chat personalities from + * the cairn-community catalog. Fetches the separate personalities.json manifest + * (cache-first, refreshable), lists each entry with its full prompt text, and + * installs the chosen one into the shared installed list. Installed entries are + * NOT auto-selected — the user picks one in the personality picker afterwards. + * + * The full prompt is shown on every card because it is appended verbatim to the + * chat system prompt — transparency matters for untrusted community text. + */ + +import { useState, useEffect, useMemo, useCallback } from "react"; +import { RefreshCw, Loader2, Check, Download, WifiOff, Search, ExternalLink } from "lucide-react"; +import { ModalShell } from "@/components/ui/modal-shell"; +import { Button } from "@/components/ui/button"; +import { useCairnStore } from "@/store"; +import { useShallow } from "zustand/react/shallow"; +import { cn } from "@/lib/utils"; +import type { PersonalitiesFetchResult, RegistryPersonalityEntry } from "@/types"; + +const inputCls = + "w-full rounded border border-[var(--border)] bg-[var(--surface)] text-[var(--text-primary)] text-sm pl-8 pr-3 py-2 focus:outline-none"; + +const emptyResult: PersonalitiesFetchResult = { + manifest: { version: 1, updatedAt: "", personalities: [] }, + fromCache: false, +}; + +export function BrowsePersonalitiesModal({ onClose }: { onClose: () => void }) { + const { installedPersonalities, installCommunityPersonality } = useCairnStore( + useShallow((s) => ({ + installedPersonalities: s.aiConfig.installedPersonalities ?? [], + installCommunityPersonality: s.installCommunityPersonality, + })), + ); + + const [result, setResult] = useState(null); + const [loading, setLoading] = useState(true); + const [refreshing, setRefreshing] = useState(false); + const [query, setQuery] = useState(""); + const [activeCategory, setActiveCategory] = useState(null); + const [installing, setInstalling] = useState(null); + const [installError, setInstallError] = useState(null); + + const load = useCallback(async (force: boolean) => { + const reg = window.electron?.registry; + if (!reg?.fetchPersonalities) { + setLoading(false); + return; + } + if (force) setRefreshing(true); + else setLoading(true); + try { + const r = force ? await reg.refreshPersonalities() : await reg.fetchPersonalities(); + setResult(r); + } catch (err) { + setResult({ ...emptyResult, error: err instanceof Error ? err.message : String(err) }); + } finally { + setLoading(false); + setRefreshing(false); + } + }, []); + + useEffect(() => { + void load(false); // eslint-disable-line react-hooks/set-state-in-effect + }, [load]); + + const personalities = useMemo(() => result?.manifest.personalities ?? [], [result]); + + const categories = useMemo(() => { + const set = new Set(); + for (const p of personalities) if (p.category) set.add(p.category); + return [...set].sort(); + }, [personalities]); + + const filtered = useMemo(() => { + const q = query.trim().toLowerCase(); + return personalities.filter((p) => { + if (activeCategory && p.category !== activeCategory) return false; + if (!q) return true; + return ( + p.definition.name.toLowerCase().includes(q) || + p.blurb.toLowerCase().includes(q) || + (p.category ?? "").toLowerCase().includes(q) || + p.tags.some((t) => t.toLowerCase().includes(q)) + ); + }); + }, [personalities, query, activeCategory]); + + // Installed state, keyed by communityId (== the entry id). + const isInstalled = useCallback( + (entry: RegistryPersonalityEntry): boolean => + (installedPersonalities ?? []).some((p) => p.communityId === entry.id), + [installedPersonalities], + ); + + const runInstall = useCallback( + async (entry: RegistryPersonalityEntry) => { + setInstalling(entry.id); + setInstallError(null); + try { + await installCommunityPersonality(entry); + } catch (err) { + setInstallError(err instanceof Error ? err.message : "Couldn't install the personality."); + } finally { + setInstalling(null); + } + }, + [installCommunityPersonality], + ); + + return ( + + Browse Community Personalities + + } + description="Install ready-made tone & style rules for chat. The full prompt is shown — it's appended to the system prompt verbatim." + > + {/* Toolbar: search + refresh */} +
+
+
+ + setQuery(e.target.value)} + /> +
+ +
+ + {categories.length > 0 && ( +
+ setActiveCategory(null)} /> + {categories.map((cat) => ( + setActiveCategory(cat)} /> + ))} +
+ )} +
+ + {/* Provenance / error banner */} + {result?.error && ( +
+ + {result.fromCache + ? "Showing the cached catalog — couldn't reach the registry." + : `Couldn't load the registry: ${result.error}`} +
+ )} + {installError && ( +
+ {installError} +
+ )} + + {/* List */} +
+ {loading ? ( +
+ +
+ ) : filtered.length === 0 ? ( +

+ {personalities.length === 0 ? "No community personalities available." : "No matches."} +

+ ) : ( + filtered.map((entry) => { + const def = entry.definition; + const installed = isInstalled(entry); + const busy = installing === entry.id; + return ( +
+
+
+ +
+ +
+
+ {def.name} + {entry.category && ( + + {entry.category} + + )} + by {entry.author} + {entry.homepage && ( + + source + + )} +
+

{entry.blurb}

+ {def.description && ( +

{def.description}

+ )} + {/* Full prompt — transparency: this text is appended verbatim + to the chat system prompt. */} +
+

+ System prompt layer +

+

+ {def.prompt} +

+
+
+ +
+ {installed ? ( + + Added + + ) : ( + + )} +
+
+
+ ); + }) + )} +
+ +

+ Added personalities join your installed list. Select one in the personality + picker next to the model selector in chat — "Default" means no personality. +

+
+ ); +} + +function TagChip({ label, active, onClick }: { label: string; active: boolean; onClick: () => void }) { + return ( + + ); +} diff --git a/src/components/chat/ChatInputArea.tsx b/src/components/chat/ChatInputArea.tsx index e93b72035..7e86304dd 100644 --- a/src/components/chat/ChatInputArea.tsx +++ b/src/components/chat/ChatInputArea.tsx @@ -14,6 +14,7 @@ import React, { useState, useCallback } from "react"; import { ChatInput, type SlashCommand, type SuggestionItem } from "@/components/chat/ChatInput"; import { ProviderModelPicker } from "@/components/ui/provider-model-picker"; +import { PersonalityPicker } from "@/components/ui/personality-picker"; import { readAttachments, type AttachmentItem } from "@/lib/read-attachments"; import { cn } from "@/lib/utils"; @@ -108,6 +109,7 @@ export const ChatInputArea = React.forwardRef
{providerModelTarget && } + {providerModelTarget === "ai" && } {statusText && (

{statusText} diff --git a/src/components/chat/chat-panel/index.tsx b/src/components/chat/chat-panel/index.tsx index f4bd4721e..45bb2c686 100644 --- a/src/components/chat/chat-panel/index.tsx +++ b/src/components/chat/chat-panel/index.tsx @@ -393,6 +393,10 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP systemPrompt = `## Context\n- **Date:** ${date}\n\n${GRAPH_SYSTEM_PROMPT}\n\n--- CURRENT GRAPH SNAPSHOT ---\n${graphContext}`; } + // Active chat personality (Default = none). The main process appends its + // prompt to the system prompt as a delimited style layer. + const activePersonality = aiConfig.installedPersonalities?.find((p) => p.id === aiConfig.personalityId); + const formatChatHistory = (msgs: typeof messages) => { const history: ChatHistoryEntry[] = []; msgs.slice(-40).forEach((m) => { @@ -465,6 +469,9 @@ export function ChatPanel({ prefill, onPrefillConsumed, popoutMode }: ChatPanelP isReasoningModel: getModelInfo(aiConfig.model)?.reasoning === true, }, systemPrompt, + // Active chat personality (Default = none). The main process appends it + // to the system prompt as a delimited style layer. + personality: activePersonality ? { name: activePersonality.name, prompt: activePersonality.prompt } : undefined, images: attachmentsToSend?.map((a) => ({ name: a.name, dataUrl: a.dataUrl, kind: a.kind })), // Subagents is now a GLOBAL AI setting (aiConfig.subagentsEnabled), not a // per-thread flag. Ignored server-side / here for the localllm provider. diff --git a/src/components/settings/AISettings.tsx b/src/components/settings/AISettings.tsx index 27de29220..acc92296d 100644 --- a/src/components/settings/AISettings.tsx +++ b/src/components/settings/AISettings.tsx @@ -3,7 +3,7 @@ import { useCairnStore } from "@/store"; import { useShallow } from "zustand/react/shallow"; import { useEffect, useState } from "react"; -import { Cpu, Globe, Download } from "lucide-react"; +import { Cpu, Globe, Download, Plus, Trash2, Sparkles, X } from "lucide-react"; import { cn } from "@/lib/utils"; import { contextLimitForModel, modelInfoForModel } from "@/lib/models-dev"; import { SettingsGroup, SettingsRow, Toggle, StepperSettingsRow } from "./shared"; @@ -11,11 +11,15 @@ import { MCPServerSettings } from "./MCPSettings"; import { LlamaServerConsole } from "./LlamaServerConsole"; import { ProviderManager } from "./ProviderManager"; import { BrowseProvidersModal } from "./tools/BrowseProvidersModal"; +import { BrowsePersonalitiesModal } from "@/components/chat/BrowsePersonalitiesModal"; export function AISettings() { - const { aiConfig, setAIConfig } = useCairnStore(useShallow((s) => ({ + const { aiConfig, setAIConfig, setPersonality, removePersonality, createCustomPersonality } = useCairnStore(useShallow((s) => ({ aiConfig: s.aiConfig, setAIConfig: s.setAIConfig, + setPersonality: s.setPersonality, + removePersonality: s.removePersonality, + createCustomPersonality: s.createCustomPersonality, }))); // General config destructuring. Connection fields (baseUrl/apiKey/model) are @@ -37,6 +41,11 @@ export function AISettings() { const [detectedContext, setDetectedContext] = useState(null); const [autoState, setAutoState] = useState<"idle" | "loading" | "detected" | "not_found">("idle"); const [browsingProviders, setBrowsingProviders] = useState(false); + const [browsingPersonalities, setBrowsingPersonalities] = useState(false); + const [creatingPersonality, setCreatingPersonality] = useState(false); + const [personaName, setPersonaName] = useState(""); + const [personaDescription, setPersonaDescription] = useState(""); + const [personaPrompt, setPersonaPrompt] = useState(""); useEffect(() => { if (provider === "localllm") return; let cancelled = false; @@ -271,10 +280,137 @@ export function AISettings() { )} + {/* ── Chat personalities ── */} + + + + + + {aiConfig.installedPersonalities && aiConfig.installedPersonalities.length > 0 && ( + +

+ {aiConfig.installedPersonalities.map((p) => { + const isActive = p.id === aiConfig.personalityId; + return ( +
setPersonality(isActive ? null : p.id)} + title={isActive ? "Click to switch back to Default" : "Set as active"} + > + + {p.name} + {p.source === "custom" && ( + custom + )} + + {isActive ? "Active" : "Default"} + + +
+ ); + })} +
+ + )} + + + + + + {creatingPersonality && ( +
+ setPersonaName(e.target.value)} + /> + setPersonaDescription(e.target.value)} + /> +