diff --git a/apps/desktop/src/chat/transport/use-transport.ts b/apps/desktop/src/chat/transport/use-transport.ts
index 47a1d7a05b..85eeac9f19 100644
--- a/apps/desktop/src/chat/transport/use-transport.ts
+++ b/apps/desktop/src/chat/transport/use-transport.ts
@@ -1,5 +1,6 @@
+import { useQuery } from "@tanstack/react-query";
import type { LanguageModel, ToolSet } from "ai";
-import { useEffect, useMemo, useState } from "react";
+import { useMemo } from "react";
import { commands as templateCommands } from "@hypr/plugin-template";
@@ -10,6 +11,7 @@ import { useLanguageModel } from "~/ai/hooks";
import type { ContextRef } from "~/chat/context/entities";
import { hydrateSessionContextFromFs } from "~/chat/context/session-context-hydrator";
import { useToolRegistry } from "~/contexts/tool";
+import { useConfigValues } from "~/shared/config";
import * as main from "~/store/tinybase/store/main";
function renderHumanContext(
@@ -82,49 +84,59 @@ export function useTransport(
const registry = useToolRegistry();
const configuredModel = useLanguageModel("chat");
const model = modelOverride ?? configuredModel;
- const language = main.UI.useValue("ai_language", main.STORE_ID) ?? "en";
- const [systemPrompt, setSystemPrompt] = useState
();
-
- useEffect(() => {
- if (systemPromptOverride) {
- setSystemPrompt(systemPromptOverride);
- return;
- }
-
- let stale = false;
-
- templateCommands
- .render({
+ const {
+ ai_language: language,
+ chat_style_tone: styleTone,
+ chat_warmth: warmth,
+ chat_enthusiasm: enthusiasm,
+ chat_headers_lists: headersLists,
+ chat_emoji: emoji,
+ chat_custom_instructions: customInstructions,
+ } = useConfigValues([
+ "ai_language",
+ "chat_style_tone",
+ "chat_warmth",
+ "chat_enthusiasm",
+ "chat_headers_lists",
+ "chat_emoji",
+ "chat_custom_instructions",
+ ] as const);
+ const normalizedCustomInstructions = customInstructions.trim();
+
+ const systemPromptQuery = useQuery({
+ queryKey: [
+ "chat-system-prompt",
+ language,
+ styleTone,
+ warmth,
+ enthusiasm,
+ headersLists,
+ emoji,
+ normalizedCustomInstructions,
+ ],
+ enabled: systemPromptOverride === undefined,
+ staleTime: Infinity,
+ queryFn: async () => {
+ const result = await templateCommands.render({
chatSystem: {
language,
+ styleTone,
+ warmth,
+ enthusiasm,
+ headersLists,
+ emoji,
+ customInstructions: normalizedCustomInstructions,
},
- })
- .then((result) => {
- if (stale) {
- return;
- }
-
- if (result.status === "ok") {
- setSystemPrompt(result.data);
- } else {
- setSystemPrompt("");
- }
- })
- .catch((error) => {
- console.error(error);
- if (!stale) {
- setSystemPrompt("");
- }
});
- return () => {
- stale = true;
- };
- }, [language, systemPromptOverride]);
+ return result.status === "ok" ? result.data : "";
+ },
+ });
- const effectiveSystemPrompt = systemPromptOverride ?? systemPrompt;
+ const effectiveSystemPrompt = systemPromptOverride ?? systemPromptQuery.data;
const isSystemPromptReady =
- typeof systemPromptOverride === "string" || systemPrompt !== undefined;
+ typeof systemPromptOverride === "string" ||
+ systemPromptQuery.data !== undefined;
const tools = useMemo(() => {
const localTools = registry.getTools("chat-general");
diff --git a/apps/desktop/src/settings/index.tsx b/apps/desktop/src/settings/index.tsx
index 7114247334..5c030f0121 100644
--- a/apps/desktop/src/settings/index.tsx
+++ b/apps/desktop/src/settings/index.tsx
@@ -12,6 +12,7 @@ import {
import { SettingsLab } from "./lab";
import { AgentIntegrations } from "./lab/agent-integrations";
import { DeveloperSettings } from "./lab/developer";
+import { SettingsPersonalization } from "./personalization";
import { SettingsTodo } from "./todo";
import { LLM } from "~/settings/ai/llm";
@@ -85,6 +86,8 @@ function SettingsView({ tab }: { tab: Extract }) {
return ;
case "intelligence":
return ;
+ case "personalization":
+ return ;
case "memory":
return ;
case "todo":
diff --git a/apps/desktop/src/settings/personalization/index.tsx b/apps/desktop/src/settings/personalization/index.tsx
new file mode 100644
index 0000000000..12ab1d9dd1
--- /dev/null
+++ b/apps/desktop/src/settings/personalization/index.tsx
@@ -0,0 +1,303 @@
+import { useForm } from "@tanstack/react-form";
+
+import { Button } from "@hypr/ui/components/ui/button";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue,
+} from "@hypr/ui/components/ui/select";
+import { Textarea } from "@hypr/ui/components/ui/textarea";
+
+import { SettingsPageTitle } from "~/settings/page-title";
+import { useConfigValues } from "~/shared/config";
+import * as settings from "~/store/tinybase/store/settings";
+import { type Tab, useTabs } from "~/store/zustand/tabs";
+
+const PERSONALIZATION_KEYS = [
+ "chat_style_tone",
+ "chat_warmth",
+ "chat_enthusiasm",
+ "chat_headers_lists",
+ "chat_emoji",
+ "chat_custom_instructions",
+] as const;
+
+const STYLE_TONE_OPTIONS = [
+ { value: "professional", label: "Professional" },
+ { value: "friendly", label: "Friendly" },
+ { value: "concise", label: "Concise" },
+ { value: "technical", label: "Technical" },
+] as const;
+
+const CHARACTERISTIC_OPTIONS = [
+ { value: "default", label: "Default" },
+ { value: "more", label: "More" },
+ { value: "less", label: "Less" },
+] as const;
+
+const EMOJI_OPTIONS = [
+ { value: "default", label: "Default" },
+ { value: "less", label: "Less" },
+ { value: "none", label: "None" },
+] as const;
+
+function usePersonalizationForm() {
+ const values = useConfigValues(PERSONALIZATION_KEYS);
+
+ const setPartialValues = settings.UI.useSetPartialValuesCallback(
+ (row: Partial>) =>
+ row,
+ [],
+ settings.STORE_ID,
+ );
+
+ return useForm({
+ defaultValues: {
+ chat_style_tone: values.chat_style_tone,
+ chat_warmth: values.chat_warmth,
+ chat_enthusiasm: values.chat_enthusiasm,
+ chat_headers_lists: values.chat_headers_lists,
+ chat_emoji: values.chat_emoji,
+ chat_custom_instructions: values.chat_custom_instructions,
+ },
+ listeners: {
+ onChange: ({ formApi }) => {
+ void formApi.handleSubmit();
+ },
+ },
+ onSubmit: ({ value }) => {
+ setPartialValues(value);
+ },
+ });
+}
+
+export function SettingsPersonalization() {
+ const form = usePersonalizationForm();
+ const tabs = useTabs((state) => state.tabs);
+ const openNew = useTabs((state) => state.openNew);
+ const select = useTabs((state) => state.select);
+ const updatePromptsTabState = useTabs((state) => state.updatePromptsTabState);
+
+ const openMeetingNotesEditor = () => {
+ const promptsTab = tabs.find(
+ (tab): tab is Extract => tab.type === "prompts",
+ );
+
+ if (!promptsTab) {
+ openNew({
+ type: "prompts",
+ state: {
+ selectedTask: "enhance",
+ },
+ });
+ return;
+ }
+
+ updatePromptsTabState(promptsTab, {
+ ...promptsTab.state,
+ selectedTask: "enhance",
+ });
+ select(promptsTab);
+ };
+
+ return (
+
+
+
+
+
+
Chat
+
+ Adjust Charlie's default voice, formatting, and response rules.
+
+
+
+
+
+ {(field) => (
+
+ )}
+
+
+
+ {(field) => (
+
+ )}
+
+
+
+ {(field) => (
+
+ )}
+
+
+
+ {(field) => (
+
+ )}
+
+
+
+ {(field) => (
+
+ )}
+
+
+
+ {(field) => (
+
+ )}
+
+
+
+
+
+
+
+ Meeting Notes
+
+
+ Open the advanced editor for meeting-note and title-generation
+ prompts.
+
+
+
+
+
+
+ );
+}
+
+function SelectSettingRow({
+ title,
+ description,
+ value,
+ onChange,
+ options,
+}: {
+ title: string;
+ description: string;
+ value: string;
+ onChange: (value: string) => void;
+ options: ReadonlyArray<{ value: string; label: string }>;
+}) {
+ return (
+
+
+
{title}
+
{description}
+
+
+
+
+ );
+}
+
+function TextareaSettingRow({
+ title,
+ description,
+ value,
+ onChange,
+ placeholder,
+}: {
+ title: string;
+ description: string;
+ value: string;
+ onChange: (value: string) => void;
+ placeholder: string;
+}) {
+ return (
+
+
+
{title}
+
{description}
+
+
+
+ );
+}
+
+function ActionSettingRow({
+ title,
+ description,
+ actionLabel,
+ onAction,
+}: {
+ title: string;
+ description: string;
+ actionLabel: string;
+ onAction: () => void;
+}) {
+ return (
+
+
+
{title}
+
{description}
+
+
+
+
+ );
+}
diff --git a/apps/desktop/src/sidebar/settings.tsx b/apps/desktop/src/sidebar/settings.tsx
index efd53ee544..ac93a67355 100644
--- a/apps/desktop/src/sidebar/settings.tsx
+++ b/apps/desktop/src/sidebar/settings.tsx
@@ -10,6 +10,7 @@ import {
CodeIcon,
FlaskConical,
LockIcon,
+ SmileIcon,
SmartphoneIcon,
SparklesIcon,
TicketIcon,
@@ -44,6 +45,7 @@ function getBaseGroups() {
items: [
{ id: "transcription", label: "Transcription", icon: AudioLinesIcon },
{ id: "intelligence", label: "Intelligence", icon: SparklesIcon },
+ { id: "personalization", label: "Personalization", icon: SmileIcon },
{ id: "memory", label: "Memory", icon: BrainIcon },
{
action: "open-templates",
diff --git a/apps/desktop/src/store/tinybase/store/settings.ts b/apps/desktop/src/store/tinybase/store/settings.ts
index 02f62fa7b5..47316881ec 100644
--- a/apps/desktop/src/store/tinybase/store/settings.ts
+++ b/apps/desktop/src/store/tinybase/store/settings.ts
@@ -118,6 +118,36 @@ export const SETTINGS_MAPPING = {
type: "string",
path: ["general", "selected_template_id"],
},
+ chat_style_tone: {
+ type: "string",
+ path: ["personalization", "chat_style_tone"],
+ default: "professional" as string,
+ },
+ chat_warmth: {
+ type: "string",
+ path: ["personalization", "chat_warmth"],
+ default: "default" as string,
+ },
+ chat_enthusiasm: {
+ type: "string",
+ path: ["personalization", "chat_enthusiasm"],
+ default: "default" as string,
+ },
+ chat_headers_lists: {
+ type: "string",
+ path: ["personalization", "chat_headers_lists"],
+ default: "default" as string,
+ },
+ chat_emoji: {
+ type: "string",
+ path: ["personalization", "chat_emoji"],
+ default: "default" as string,
+ },
+ chat_custom_instructions: {
+ type: "string",
+ path: ["personalization", "chat_custom_instructions"],
+ default: "" as string,
+ },
todo_linear_filter: {
type: "string",
path: ["todo", "linear_filter"],
diff --git a/apps/desktop/src/store/zustand/tabs/schema.ts b/apps/desktop/src/store/zustand/tabs/schema.ts
index b3191fc9dd..73192acaf8 100644
--- a/apps/desktop/src/store/zustand/tabs/schema.ts
+++ b/apps/desktop/src/store/zustand/tabs/schema.ts
@@ -43,6 +43,7 @@ export type SettingsTab =
| "developer"
| "transcription"
| "intelligence"
+ | "personalization"
| "memory"
| "todo";
@@ -59,6 +60,7 @@ export const normalizeSettingsTab = (
case "developer":
case "transcription":
case "intelligence":
+ case "personalization":
case "memory":
case "todo":
return tab;
diff --git a/apps/web/src/routes/_view/pricing.tsx b/apps/web/src/routes/_view/pricing.tsx
index 66b6a688b7..af99ebdf88 100644
--- a/apps/web/src/routes/_view/pricing.tsx
+++ b/apps/web/src/routes/_view/pricing.tsx
@@ -46,7 +46,7 @@ const PRICING_FAQS = [
{
question: "What are custom instructions?",
answer:
- "Custom instructions let you override Char's default system prompt by configuring template variables and the overall instructions given to the AI.",
+ "Custom instructions let you tune Charlie's default response style and add standing guidance for chat replies without rewriting the full prompt.",
},
{
question: "What are shortcuts?",
diff --git a/crates/template-app/assets/chat.system.md.jinja b/crates/template-app/assets/chat.system.md.jinja
index d9b3f8a67b..490547a080 100644
--- a/crates/template-app/assets/chat.system.md.jinja
+++ b/crates/template-app/assets/chat.system.md.jinja
@@ -10,6 +10,45 @@ Current date: {{ ""|current_date }}
- Always keep your responses concise, professional, and directly relevant to the user's questions.
- Your primary source of truth is the meeting transcript. Try to generate responses primarily from the transcript, and then the summary or other information (unless the user asks for something specific).
+{%- if style_tone != "professional" || warmth != "default" || enthusiasm != "default" || headers_lists != "default" || emoji != "default" || !custom_instructions.is_empty() %}
+
+# Personalization
+
+{% if style_tone == "friendly" %}
+
+- Use a friendly and approachable tone.
+ {% elif style_tone == "concise" %}
+- Be extra direct and concise.
+ {% elif style_tone == "technical" %}
+- Prefer a technical and analytical tone.
+ {% endif %}
+ {% if warmth == "more" %}
+- Be warmer and more personable than the default.
+ {% elif warmth == "less" %}
+- Keep the tone more neutral and reserved.
+ {% endif %}
+ {% if enthusiasm == "more" %}
+- Sound more upbeat than the default.
+ {% elif enthusiasm == "less" %}
+- Keep the tone calm and understated.
+ {% endif %}
+ {% if headers_lists == "more" %}
+- Use headings and bullet lists more often when they improve clarity.
+ {% elif headers_lists == "less" %}
+- Prefer short paragraphs unless extra structure is necessary.
+ {% endif %}
+ {% if emoji == "less" %}
+- Avoid emojis unless the user explicitly asks for them.
+ {% elif emoji == "none" %}
+- Do not use emojis.
+ {% endif %}
+ {% if !custom_instructions.is_empty() %}
+- Follow these additional instructions:
+ {{ custom_instructions }}
+ {% endif %}
+
+{%- endif %}
+
# Formatting Guidelines
- Your response would be highly likely to be paragraphs with combined information about your thought and whatever note (in markdown format) you generated.
diff --git a/crates/template-app/src/chat.rs b/crates/template-app/src/chat.rs
index 5d7a6f3ae9..838e0f77a8 100644
--- a/crates/template-app/src/chat.rs
+++ b/crates/template-app/src/chat.rs
@@ -19,6 +19,12 @@ common_derives! {
#[template(path = "chat.system.md.jinja")]
pub struct ChatSystem {
pub language: Option,
+ pub style_tone: String,
+ pub warmth: String,
+ pub enthusiasm: String,
+ pub headers_lists: String,
+ pub emoji: String,
+ pub custom_instructions: String,
}
}
@@ -33,12 +39,19 @@ common_derives! {
#[cfg(test)]
mod tests {
use super::*;
+ use askama::Template;
use hypr_askama_utils::tpl_snapshot_with_assert;
tpl_snapshot_with_assert!(
test_chat_system,
ChatSystem {
language: None,
+ style_tone: "professional".to_string(),
+ warmth: "default".to_string(),
+ enthusiasm: "default".to_string(),
+ headers_lists: "default".to_string(),
+ emoji: "default".to_string(),
+ custom_instructions: String::new(),
},
|v| !v.contains("Context:"),
fixed_date = "2025-01-01",
@@ -61,6 +74,25 @@ mod tests {
- Information (when it's not rewriting the note, it shouldn't be inside `blocks. Only re-written version of the note should be inside` blocks.) Try your best to put markdown notes inside ``` blocks.
"#);
+ #[test]
+ fn chat_system_renders_personalization_section() {
+ let rendered = ChatSystem {
+ language: Some("en".to_string()),
+ style_tone: "technical".to_string(),
+ warmth: "less".to_string(),
+ enthusiasm: "more".to_string(),
+ headers_lists: "less".to_string(),
+ emoji: "none".to_string(),
+ custom_instructions: "Lead with the answer.".to_string(),
+ }
+ .render()
+ .unwrap();
+
+ assert!(rendered.contains("# Personalization"));
+ assert!(rendered.contains("technical and analytical tone"));
+ assert!(rendered.contains("Lead with the answer."));
+ }
+
tpl_snapshot_with_assert!(
test_context_block_wrapped,
ContextBlock {
diff --git a/packages/pricing/src/tiers.ts b/packages/pricing/src/tiers.ts
index 094d8287e3..4066f209ad 100644
--- a/packages/pricing/src/tiers.ts
+++ b/packages/pricing/src/tiers.ts
@@ -57,7 +57,7 @@ export const PLAN_TIERS: PlanTierData[] = [
{ label: "Everything in Free", included: true },
{ label: "Cloud Services (STT & LLM)", included: true },
{ label: "Speaker Identification", included: "partial" },
- { label: "Advanced Templates", included: false },
+ { label: "Custom Instructions", included: false },
{ label: "Cloud Sync", included: false },
{ label: "Shareable Links", included: false },
],
@@ -71,7 +71,7 @@ export const PLAN_TIERS: PlanTierData[] = [
features: [
{ label: "Everything in Lite", included: true },
{ label: "Change Playback Rates", included: true },
- { label: "Advanced Templates", included: true },
+ { label: "Custom Instructions", included: true },
{ label: "Integrations", included: true },
{ label: "Cloud Sync", included: "partial" },
{ label: "Shareable Links", included: "partial" },
@@ -131,7 +131,7 @@ export const MARKETING_PLAN_TIERS: MarketingPlanData[] = [
{ label: "Speaker Identification", included: "partial" },
{ label: "Change Playback Rates", included: false },
{ label: "Integrations", included: false },
- { label: "Advanced Templates", included: false },
+ { label: "Custom Instructions", included: false },
{ label: "Folders View", included: false },
{ label: "Cloud Sync", included: false },
{ label: "Shareable Links", included: false },
@@ -156,7 +156,7 @@ export const MARKETING_PLAN_TIERS: MarketingPlanData[] = [
tooltip:
"Google Calendar is available now. Additional integrations are in progress.",
},
- { label: "Advanced Templates", included: "partial" },
+ { label: "Custom Instructions", included: "partial" },
{ label: "Folders View", included: "partial" },
{
label: "Connect to OpenClaw",
diff --git a/packages/store/src/tinybase.ts b/packages/store/src/tinybase.ts
index 46bdc51a47..6e705e7013 100644
--- a/packages/store/src/tinybase.ts
+++ b/packages/store/src/tinybase.ts
@@ -201,4 +201,10 @@ export const valueSchemaForTinybase = {
current_llm_model: { type: "string" },
current_stt_provider: { type: "string" },
current_stt_model: { type: "string" },
+ chat_style_tone: { type: "string" },
+ chat_warmth: { type: "string" },
+ chat_enthusiasm: { type: "string" },
+ chat_headers_lists: { type: "string" },
+ chat_emoji: { type: "string" },
+ chat_custom_instructions: { type: "string" },
} as const satisfies InferTinyBaseSchema;
diff --git a/packages/store/src/zod.ts b/packages/store/src/zod.ts
index 3e8851085f..3b1dc584fb 100644
--- a/packages/store/src/zod.ts
+++ b/packages/store/src/zod.ts
@@ -299,6 +299,12 @@ export const generalSchema = z.object({
current_llm_model: z.string().optional(),
current_stt_provider: z.string().optional(),
current_stt_model: z.string().optional(),
+ chat_style_tone: z.string().default("professional"),
+ chat_warmth: z.string().default("default"),
+ chat_enthusiasm: z.string().default("default"),
+ chat_headers_lists: z.string().default("default"),
+ chat_emoji: z.string().default("default"),
+ chat_custom_instructions: z.string().default(""),
timezone: z.string().optional(),
week_start: z.string().optional(),
});
diff --git a/plugins/template/js/bindings.gen.ts b/plugins/template/js/bindings.gen.ts
index d34c759fa3..195ab2bd85 100644
--- a/plugins/template/js/bindings.gen.ts
+++ b/plugins/template/js/bindings.gen.ts
@@ -46,7 +46,7 @@ export type AccountInfo = { userId: string; email: string | null; fullName: stri
export type ActivityCaptureSystem = { language: string | null }
export type ActivityCaptureUser = { appName: string; windowTitle: string | null; reason: string; fingerprint: string }
export type BugReport = { description: string; platform: string; arch: string; osVersion: string; appVersion: string; source: string }
-export type ChatSystem = { language: string | null }
+export type ChatSystem = { language: string | null; styleTone: string; warmth: string; enthusiasm: string; headersLists: string; emoji: string; customInstructions: string }
export type ContextBlock = { contexts: SessionContext[] }
export type DailySummaryAnalysis = { time: string; appName: string; windowTitle: string | null; reason: string; summary: string }
export type DailySummaryAppStat = { appName: string; count: number }