From 192e6e3891291000cafc083c77ae7a312727cafd Mon Sep 17 00:00:00 2001 From: Jacob Schmidt Date: Sun, 1 Mar 2026 16:07:59 -0600 Subject: [PATCH] feat: add AI coaching, conversation persistence, and LLamaSharp integration - Add Journal.AI project with LLamaSharp-based AI service (Phi-3 model) - Implement coach sessions (daily check-in, evening review, weekly review) - Add conversation CRUD with SQLCipher persistence - AI chat with full conversation history for context-aware replies - Frontend: CoachPanel, AI stores, conversation stores, side panel UI - Conversation list with create, rename, and delete support - Fix Phi-3 output quality (system prompt leaking, token cleanup, JSON filtering) - Fix CREATEDRAFT kind override in coach sessions Co-Authored-By: Oz --- Journal.AI/Coach-Rules.txt | 35 + Journal.AI/Daily-Check-In.txt | 20 + Journal.AI/Evening-Review.txt | 19 + Journal.AI/Journal.AI.csproj | 25 + Journal.AI/LlamaSharpAiService.cs | 336 +++++++++ Journal.AI/LlamaSharpCoachService.cs | 181 +++++ Journal.AI/ServiceCollectionExtensions.cs | 73 ++ Journal.AI/Weekly-Review.txt | 19 + Journal.App/src/lib/backend/ai.ts | 222 ++++++ Journal.App/src/lib/backend/conversations.ts | 166 +++++ .../src/lib/components/CoachPanel.svelte | 691 ++++++++++++++++++ .../src/lib/components/EditorPanel.svelte | 11 + Journal.App/src/lib/components/Navbar.svelte | 1 + .../src/lib/components/SidePanel.svelte | 421 ++++++++++- Journal.App/src/lib/stores/ai.ts | 140 ++++ Journal.App/src/lib/stores/conversations.ts | 222 ++++++ Journal.App/src/lib/stores/settings.ts | 1 + Journal.App/src/routes/+page.svelte | 3 +- Journal.Core/Dtos/CoachDtos.cs | 32 + Journal.Core/Dtos/CommandDtos.cs | 6 + Journal.Core/Dtos/ConversationDtos.cs | 28 + Journal.Core/Entry.cs | 89 ++- Journal.Core/Models/Conversation.cs | 33 + Journal.Core/Models/ConversationMessage.cs | 38 + Journal.Core/Models/JournalConfig.cs | 3 +- .../Repositories/IConversationRepository.cs | 14 + .../SqliteConversationRepository.cs | 217 ++++++ Journal.Core/ServiceCollectionExtensions.cs | 4 + Journal.Core/Services/Ai/DisabledAiService.cs | 3 + .../Services/Ai/DisabledCoachService.cs | 26 + Journal.Core/Services/Ai/IAiService.cs | 1 + Journal.Core/Services/Ai/ICoachService.cs | 10 + .../Services/Ai/PythonSidecarAiService.cs | 7 + .../Services/Config/JournalConfigService.cs | 24 +- .../Conversations/ConversationService.cs | 68 ++ .../Conversations/IConversationService.cs | 14 + .../Database/JournalDatabaseService.cs | 22 +- Journal.Sidecar/App.cs | 2 + Journal.Sidecar/Journal.Sidecar.csproj | 1 + Journal.Sidecar/LocalWhisperS2TService.cs | 4 +- Journal.SmokeTests/GlobalUsings.cs | 1 + Journal.SmokeTests/Program.Shared.cs | 3 + Journal.SmokeTests/Program.VaultTests.cs | 3 + Journal.WebGateway/Journal.WebGateway.csproj | 1 + Journal.WebGateway/Program.cs | 2 + Journal.slnx | 1 + docs/frontend-csharp-backend-wiring.md | 218 ------ 47 files changed, 3225 insertions(+), 236 deletions(-) create mode 100644 Journal.AI/Coach-Rules.txt create mode 100644 Journal.AI/Daily-Check-In.txt create mode 100644 Journal.AI/Evening-Review.txt create mode 100644 Journal.AI/Journal.AI.csproj create mode 100644 Journal.AI/LlamaSharpAiService.cs create mode 100644 Journal.AI/LlamaSharpCoachService.cs create mode 100644 Journal.AI/ServiceCollectionExtensions.cs create mode 100644 Journal.AI/Weekly-Review.txt create mode 100644 Journal.App/src/lib/backend/ai.ts create mode 100644 Journal.App/src/lib/backend/conversations.ts create mode 100644 Journal.App/src/lib/components/CoachPanel.svelte create mode 100644 Journal.App/src/lib/stores/ai.ts create mode 100644 Journal.App/src/lib/stores/conversations.ts create mode 100644 Journal.Core/Dtos/CoachDtos.cs create mode 100644 Journal.Core/Dtos/ConversationDtos.cs create mode 100644 Journal.Core/Models/Conversation.cs create mode 100644 Journal.Core/Models/ConversationMessage.cs create mode 100644 Journal.Core/Repositories/IConversationRepository.cs create mode 100644 Journal.Core/Repositories/SqliteConversationRepository.cs create mode 100644 Journal.Core/Services/Ai/DisabledCoachService.cs create mode 100644 Journal.Core/Services/Ai/ICoachService.cs create mode 100644 Journal.Core/Services/Conversations/ConversationService.cs create mode 100644 Journal.Core/Services/Conversations/IConversationService.cs delete mode 100644 docs/frontend-csharp-backend-wiring.md diff --git a/Journal.AI/Coach-Rules.txt b/Journal.AI/Coach-Rules.txt new file mode 100644 index 0000000..b65dc90 --- /dev/null +++ b/Journal.AI/Coach-Rules.txt @@ -0,0 +1,35 @@ +You are a personal coach inside a private journaling app. +Your goals: +- Be supportive, practical, and brief. +- Ask at most {{maxQuestions}} questions. +- Suggest at most {{maxNextActions}} next actions. +- Default to small, realistic steps. + +Hard rules: +- Do not diagnose medical or mental health conditions. +- Do not use shame, guilt, or alarmist language. +- Do not claim certainty about the user's feelings or motives. +- Treat all suggestions as optional proposals. +- Output MUST be a single valid JSON object with NO text before or after it. +- Do NOT output explanations, templates, or examples — only the final JSON. +- If context is missing, make gentle assumptions and ask 1 clarifying question. + +Evidence: +- When making an observation or suggestion, include evidence snippets with recordId when available. + +You MUST respond with a single valid JSON object. No text before or after the JSON. + +Required JSON schema: +{ + "kind": "daily_checkin" | "evening_review" | "weekly_review", + "title": "", + "summary": "<2-4 sentence summary>", + "questions": [""], + "suggestedNextActions": [""], + "suggestedTags": [""], + "evidence": [{"recordId": null, "text": ""}], + "patchProposal": null +} +The top-level "kind" MUST be exactly one of: "daily_checkin", "evening_review", "weekly_review". +Set patchProposal to null unless a draft is clearly helpful. If needed use: {"kind": "createDraft", "description": "", "content": ""}. +Do NOT confuse patchProposal.kind with the top-level kind field. diff --git a/Journal.AI/Daily-Check-In.txt b/Journal.AI/Daily-Check-In.txt new file mode 100644 index 0000000..c879030 --- /dev/null +++ b/Journal.AI/Daily-Check-In.txt @@ -0,0 +1,20 @@ +{{CoachRules}} + +Task: +Generate a DAILY CHECK-IN plan for date {{dateLocal}}. + +Context (JSON): +{{contextJson}} + +Preferences (JSON): +{{preferencesJson}} + +Output: +Return a CoachPlanDto JSON with kind="daily_checkin". +Include: +- A short title +- A 2-4 sentence summary +- 1-{{maxQuestions}} questions +- 1-{{maxNextActions}} suggestedNextActions +- suggestedTags if relevant +- patchProposal that creates a fragment or dailyEntry draft ONLY if it is clearly helpful. \ No newline at end of file diff --git a/Journal.AI/Evening-Review.txt b/Journal.AI/Evening-Review.txt new file mode 100644 index 0000000..788198e --- /dev/null +++ b/Journal.AI/Evening-Review.txt @@ -0,0 +1,19 @@ +{{CoachRules}} + +Task: +Generate an EVENING REVIEW for date {{dateLocal}}. + +Context (JSON): +{{contextJson}} + +Preferences (JSON): +{{preferencesJson}} + +Output: +Return a CoachPlanDto JSON with kind="evening_review". +Include: +- Brief recap of today +- 2-4 observations with evidence +- 1-{{maxQuestions}} reflection questions +- 1-{{maxNextActions}} next actions for tomorrow +- patchProposal that creates: (optional) tomorrow-intent fragment AND/OR todos. \ No newline at end of file diff --git a/Journal.AI/Journal.AI.csproj b/Journal.AI/Journal.AI.csproj new file mode 100644 index 0000000..97186d9 --- /dev/null +++ b/Journal.AI/Journal.AI.csproj @@ -0,0 +1,25 @@ + + + + net10.0 + enable + enable + + + + + + + + + + + + + + + + + + + diff --git a/Journal.AI/LlamaSharpAiService.cs b/Journal.AI/LlamaSharpAiService.cs new file mode 100644 index 0000000..5827f28 --- /dev/null +++ b/Journal.AI/LlamaSharpAiService.cs @@ -0,0 +1,336 @@ +using System.Text; +using System.Text.RegularExpressions; +using Journal.Core.Dtos; +using Journal.Core.Models; +using Journal.Core.Services.Ai; +using LLama; +using LLama.Common; +using LLama.Sampling; + +namespace Journal.AI; + +public sealed partial class LlamaSharpAiService(JournalConfig config) : IAiService, IDisposable +{ + private const string DefaultModelUrl = + "https://huggingface.co/microsoft/Phi-3-mini-4k-instruct-gguf/resolve/main/Phi-3-mini-4k-instruct-q4.gguf"; + private const string DefaultModelFileName = "Phi-3-mini-4k-instruct-q4.gguf"; + private const string ModelSubDirectory = "ai-models"; + + private readonly string _configuredModelPath = config.GgufModelPath; + private readonly uint _contextSize = (uint)Math.Clamp(config.ModelContextTokens, 512, 4096); + private readonly int _gpuLayers = config.LlamaCppTimeout; + + private readonly Lock _sync = new(); + private string? _resolvedModelPath; + private LLamaWeights? _weights; + private bool _disposed; + + public Task HealthAsync(CancellationToken cancellationToken = default) + { + var resolved = _resolvedModelPath ?? _configuredModelPath; + var modelExists = File.Exists(resolved) || File.Exists(GetDefaultModelPath()); + var loaded = _weights is not null; + return Task.FromResult(new AiHealthDto( + Provider: "llamasharp", + Enabled: true, + Healthy: modelExists || loaded, + Message: loaded + ? "Model loaded." + : modelExists + ? "Model found (will load on first use)." + : "Model not found locally. It will be downloaded on first use.")); + } + + private static string BuildChatSystemPrompt() + { + var dateStr = DateTime.Now.ToString("MMMM d, yyyy"); + return $"You are a supportive conversational coach inside a private journaling app. " + + $"Today's date is {dateStr}. " + + $"Reply in plain natural language only. Never output JSON, code blocks, or structured data. " + + $"Be warm, practical, and concise. Do not repeat yourself."; + } + + public async Task ChatAsync(string prompt, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(prompt)) + throw new ArgumentException("Prompt is required.", nameof(prompt)); + + var raw = await RunSessionAsync(prompt, BuildChatSystemPrompt(), + maxTokens: 512, cancellationToken: cancellationToken); + return CleanChatResponse(raw); + } + + public async Task ChatWithHistoryAsync(IReadOnlyList<(string Role, string Text)> history, + string prompt, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(prompt)) + throw new ArgumentException("Prompt is required.", nameof(prompt)); + + var modelPath = await EnsureModelAsync(cancellationToken); + EnsureWeights(modelPath); + + using var context = _weights!.CreateContext(new ModelParams(modelPath) + { + ContextSize = _contextSize, + GpuLayerCount = _gpuLayers + }); + + var executor = new StatelessExecutor(_weights!, context.Params); + + // Build multi-turn Phi-3 prompt with full conversation history + var sb = new StringBuilder(); + sb.Append($"<|system|>\n{BuildChatSystemPrompt()}<|end|>\n"); + + foreach (var (role, text) in history) + { + var tag = string.Equals(role, "user", StringComparison.OrdinalIgnoreCase) ? "user" : "assistant"; + sb.Append($"<|{tag}|>\n{text}<|end|>\n"); + } + + sb.Append($"<|user|>\n{prompt}<|end|>\n"); + sb.Append("<|assistant|>\n"); + + var inferenceParams = new InferenceParams + { + MaxTokens = 512, + AntiPrompts = ["<|user|>", "<|system|>", "<|end|>", "<|endoftext|>"], + SamplingPipeline = new DefaultSamplingPipeline { Temperature = 0.7f } + }; + + var result = new StringBuilder(); + await foreach (var token in executor.InferAsync(sb.ToString(), inferenceParams, cancellationToken)) + { + result.Append(token); + } + + return CleanChatResponse(StripSpecialTokens(result.ToString())); + } + + internal async Task ChatJsonAsync(string prompt, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(prompt)) + throw new ArgumentException("Prompt is required.", nameof(prompt)); + + var dateStr = DateTime.Now.ToString("MMMM d, yyyy"); + return await RunSessionAsync(prompt, + $"You are a coaching assistant inside a private journaling app. " + + $"Today's date is {dateStr}. " + + $"You MUST respond with a single valid JSON object. No text before or after the JSON.", + maxTokens: 1024, cancellationToken: cancellationToken); + } + + public async Task SummarizeEntryAsync(string content, string? fileStem = null, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(content)) + throw new ArgumentException("Entry content is required.", nameof(content)); + + var prompt = fileStem is not null + ? $"Summarize this journal entry ({fileStem}) concisely:\n\n{content}" + : $"Summarize this journal entry concisely:\n\n{content}"; + + return await ChatAsync(prompt, cancellationToken); + } + + public async Task SummarizeAllAsync(IReadOnlyList entries, + CancellationToken cancellationToken = default) + { + if (entries is null || entries.Count == 0) + return "No entries to summarize."; + + var combined = string.Join("\n\n---\n\n", entries); + var prompt = $"Summarize the following {entries.Count} journal entries into a concise overview:\n\n{combined}"; + + return await ChatAsync(prompt, cancellationToken); + } + + public async Task> EmbedAsync(string content, + CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(content)) + throw new ArgumentException("Content is required.", nameof(content)); + + var modelPath = await EnsureModelAsync(cancellationToken); + + try + { + EnsureWeights(modelPath); + var embedder = new LLamaEmbedder(_weights!, new ModelParams(modelPath) + { + Embeddings = true, + ContextSize = _contextSize, + GpuLayerCount = _gpuLayers + }); + + var embeddingArrays = await embedder.GetEmbeddings(content, cancellationToken); + var result = new List(); + + foreach (var arr in embeddingArrays) + { + foreach (var val in arr) + { + result.Add(val); + } + } + + return result; + } + catch + { + return []; + } + } + + // ── Model download (mirrors LocalWhisperS2TService.EnsureModelAsync) ─── + + private static string GetDefaultModelPath() + { + var modelDirectory = Path.Combine( + Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), + "ProjectJournal", + ModelSubDirectory); + return Path.Combine(modelDirectory, DefaultModelFileName); + } + + private async Task EnsureModelAsync(CancellationToken cancellationToken = default) + { + // 1. Configured path takes priority if the file already exists + if (File.Exists(_configuredModelPath)) + return _configuredModelPath; + + // 2. Check the standard app-data location + var defaultPath = GetDefaultModelPath(); + if (File.Exists(defaultPath)) + return defaultPath; + + // 3. Download from HuggingFace + var modelDirectory = Path.GetDirectoryName(defaultPath)!; + Directory.CreateDirectory(modelDirectory); + + var tempPath = defaultPath + ".download"; + using var cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + cts.CancelAfter(TimeSpan.FromMinutes(30)); + + using var httpClient = new HttpClient(); + httpClient.Timeout = TimeSpan.FromMinutes(30); + + using var response = await httpClient.GetAsync(DefaultModelUrl, + HttpCompletionOption.ResponseHeadersRead, cts.Token); + response.EnsureSuccessStatusCode(); + + await using var contentStream = await response.Content.ReadAsStreamAsync(cts.Token); + await using var fileStream = new FileStream(tempPath, FileMode.Create, FileAccess.Write, FileShare.None); + await contentStream.CopyToAsync(fileStream, cts.Token); + await fileStream.FlushAsync(cts.Token); + fileStream.Close(); + + File.Move(tempPath, defaultPath, overwrite: true); + return defaultPath; + } + + // ── Session / weights lifecycle ──────────────────────────────────────── + + private async Task RunSessionAsync(string prompt, string systemPrompt, + int maxTokens, CancellationToken cancellationToken) + { + var modelPath = await EnsureModelAsync(cancellationToken); + EnsureWeights(modelPath); + + // Fresh context per call — prevents KV cache accumulation across requests + using var context = _weights!.CreateContext(new ModelParams(modelPath) + { + ContextSize = _contextSize, + GpuLayerCount = _gpuLayers + }); + + // Use StatelessExecutor with explicit Phi-3 chat template so the model + // never sees raw system text it can echo back to the user. + var executor = new StatelessExecutor(_weights!, context.Params); + + var fullPrompt = $"<|system|>\n{systemPrompt}<|end|>\n" + + $"<|user|>\n{prompt}<|end|>\n" + + $"<|assistant|>\n"; + + var inferenceParams = new InferenceParams + { + MaxTokens = maxTokens, + AntiPrompts = [ + "<|user|>", + "<|system|>", + "<|end|>", + "<|endoftext|>", + ], + SamplingPipeline = new DefaultSamplingPipeline + { + Temperature = 0.7f + } + }; + + var sb = new StringBuilder(); + + await foreach (var token in executor.InferAsync(fullPrompt, inferenceParams, cancellationToken)) + { + sb.Append(token); + } + + return StripSpecialTokens(sb.ToString()); + } + + /// Strips only Phi-3 special tokens — safe for JSON output. + private static string StripSpecialTokens(string raw) + { + var text = raw; + foreach (var marker in new[] { "<|assistant|>", "<|user|>", "<|system|>", "<|end|>", "<|endoftext|>" }) + text = text.Replace(marker, ""); + return text.Trim(); + } + + // Matches role labels like "System:", "**System:**", "**Assistant:**", "User:" etc. + private static readonly Regex RoleMarkerRegex = MyRegex(); + + /// Aggressive cleanup for conversational (non-JSON) responses. + private static string CleanChatResponse(string raw) + { + var text = StripSpecialTokens(raw); + + // Strip role markers in any formatting variant (plain, bold-markdown, etc.) + text = RoleMarkerRegex.Replace(text, ""); + + // Remove orphaned bold markers left behind after stripping + text = text.Replace("**", ""); + + // Collapse runs of 3+ newlines into 2 + text = Regex.Replace(text, @"\n{3,}", "\n\n"); + + return text.Trim(); + } + + private void EnsureWeights(string modelPath) + { + if (_weights is not null) return; + + lock (_sync) + { + if (_weights is not null) return; + + _resolvedModelPath = modelPath; + _weights = LLamaWeights.LoadFromFile(new ModelParams(modelPath) + { + ContextSize = _contextSize, + GpuLayerCount = _gpuLayers + }); + } + } + + public void Dispose() + { + if (_disposed) return; + _disposed = true; + _weights?.Dispose(); + } + + [GeneratedRegex(@"\*{0,2}(System|Assistant|User):\*{0,2}", RegexOptions.IgnoreCase | RegexOptions.Compiled, "en-US")] + private static partial Regex MyRegex(); + [GeneratedRegex(@"\n{3,}")] + private static partial Regex MyRegex1(); +} diff --git a/Journal.AI/LlamaSharpCoachService.cs b/Journal.AI/LlamaSharpCoachService.cs new file mode 100644 index 0000000..5e17f02 --- /dev/null +++ b/Journal.AI/LlamaSharpCoachService.cs @@ -0,0 +1,181 @@ +using System.Reflection; +using System.Text; +using System.Text.Json; +using Journal.Core.Dtos; +using Journal.Core.Services.Ai; + +namespace Journal.AI; + +public sealed class LlamaSharpCoachService(LlamaSharpAiService ai) : ICoachService +{ + private static readonly JsonSerializerOptions JsonOptions = new() + { + PropertyNameCaseInsensitive = true + }; + + private readonly LlamaSharpAiService _ai = ai; + private readonly string _coachRules = LoadEmbeddedResource("Coach-Rules.txt"); + private readonly string _dailyTemplate = LoadEmbeddedResource("Daily-Check-In.txt"); + private readonly string _eveningTemplate = LoadEmbeddedResource("Evening-Review.txt"); + private readonly string _weeklyTemplate = LoadEmbeddedResource("Weekly-Review.txt"); + + public async Task DailyCheckInAsync(CoachContextDto context, + CancellationToken cancellationToken = default) + { + var prefs = context.Preferences ?? new CoachPreferencesDto(); + var prompt = InterpolateTemplate(_dailyTemplate, context, prefs); + return await RunCoachPromptAsync(prompt, "daily_checkin", cancellationToken); + } + + public async Task EveningReviewAsync(CoachContextDto context, + CancellationToken cancellationToken = default) + { + var prefs = context.Preferences ?? new CoachPreferencesDto(); + var prompt = InterpolateTemplate(_eveningTemplate, context, prefs); + return await RunCoachPromptAsync(prompt, "evening_review", cancellationToken); + } + + public async Task WeeklyReviewAsync(CoachContextDto context, + CancellationToken cancellationToken = default) + { + var prefs = context.Preferences ?? new CoachPreferencesDto(); + var prompt = InterpolateTemplate(_weeklyTemplate, context, prefs); + return await RunCoachPromptAsync(prompt, "weekly_review", cancellationToken); + } + + private async Task RunCoachPromptAsync(string prompt, string fallbackKind, + CancellationToken cancellationToken) + { + var raw = await _ai.ChatJsonAsync(prompt, cancellationToken); + + // Try to extract JSON from the response + var json = ExtractJson(raw); + if (json is not null) + { + try + { + var parsed = JsonSerializer.Deserialize(json, JsonOptions); + if (parsed is not null) + return parsed with { Kind = fallbackKind }; + } + catch (JsonException) + { + // Fall through to fallback + } + } + + // Fallback: wrap raw text into a CoachPlanDto + return new CoachPlanDto( + Kind: fallbackKind, + Title: "Coach Response", + Summary: raw, + Questions: [], + SuggestedNextActions: [], + SuggestedTags: [], + Evidence: []); + } + + private string InterpolateTemplate(string template, CoachContextDto context, CoachPreferencesDto prefs) + { + var contextJson = JsonSerializer.Serialize(new + { + recentEntries = context.RecentEntries ?? [], + recentFragments = context.RecentFragments ?? [] + }); + + var preferencesJson = JsonSerializer.Serialize(new + { + prefs.MaxQuestions, + prefs.MaxNextActions + }); + + var result = template + .Replace("{{CoachRules}}", _coachRules) + .Replace("{{dateLocal}}", context.DateLocal) + .Replace("{{weekStartLocal}}", context.WeekStartLocal ?? "") + .Replace("{{weekEndLocal}}", context.WeekEndLocal ?? "") + .Replace("{{contextJson}}", contextJson) + .Replace("{{preferencesJson}}", preferencesJson) + .Replace("{{maxQuestions}}", prefs.MaxQuestions.ToString()) + .Replace("{{maxNextActions}}", prefs.MaxNextActions.ToString()); + + return result; + } + + private static string? ExtractJson(string text) + { + // 1. Try ```json ... ``` code block + var codeBlockStart = text.IndexOf("```json", StringComparison.Ordinal); + if (codeBlockStart >= 0) + { + var jsonStart = text.IndexOf('{', codeBlockStart); + var codeBlockEnd = text.IndexOf("```", codeBlockStart + 7, StringComparison.Ordinal); + if (jsonStart >= 0 && codeBlockEnd > jsonStart) + { + var candidate = text[jsonStart..codeBlockEnd].Trim(); + if (TryValidateJson(candidate)) + return candidate; + } + } + + // 2. Look specifically for {"kind" — the expected first field of CoachPlanDto + var kindMarker = text.IndexOf("{\"kind\"", StringComparison.Ordinal); + if (kindMarker < 0) + kindMarker = text.IndexOf("{ \"kind\"", StringComparison.Ordinal); + if (kindMarker >= 0) + { + var lastBrace = text.LastIndexOf('}'); + if (lastBrace > kindMarker) + { + var candidate = text[kindMarker..(lastBrace + 1)]; + if (TryValidateJson(candidate)) + return candidate; + } + } + + // 3. Fallback: try each { position until one parses as valid JSON + var searchFrom = 0; + var globalLastBrace = text.LastIndexOf('}'); + while (searchFrom < text.Length && globalLastBrace > searchFrom) + { + var bracePos = text.IndexOf('{', searchFrom); + if (bracePos < 0 || bracePos >= globalLastBrace) + break; + + var candidate = text[bracePos..(globalLastBrace + 1)]; + if (TryValidateJson(candidate)) + return candidate; + + searchFrom = bracePos + 1; + } + + return null; + } + + private static bool TryValidateJson(string json) + { + try + { + using var doc = JsonDocument.Parse(json); + return doc.RootElement.ValueKind == JsonValueKind.Object; + } + catch + { + return false; + } + } + + private static string LoadEmbeddedResource(string fileName) + { + var assembly = Assembly.GetExecutingAssembly(); + var resourceName = assembly.GetManifestResourceNames() + .FirstOrDefault(n => n.EndsWith(fileName, StringComparison.OrdinalIgnoreCase)); + + if (resourceName is null) + throw new FileNotFoundException($"Embedded resource not found: {fileName}"); + + using var stream = assembly.GetManifestResourceStream(resourceName)!; + using var reader = new StreamReader(stream, Encoding.UTF8); + return reader.ReadToEnd().Trim(); + } +} diff --git a/Journal.AI/ServiceCollectionExtensions.cs b/Journal.AI/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..a8a40cb --- /dev/null +++ b/Journal.AI/ServiceCollectionExtensions.cs @@ -0,0 +1,73 @@ +using Journal.Core.Services.Ai; +using Journal.Core.Services.Config; +using Journal.Core.Services.Sidecar; +using Microsoft.Extensions.DependencyInjection; + +namespace Journal.AI; + +public static class ServiceCollectionExtensions +{ + /// + /// Registers LLamaSharp-based AI and Coach services. + /// Call this AFTER AddFragmentServices() so that IJournalConfigService is available. + /// When the provider is "llamasharp", this replaces the default IAiService registration. + /// + public static IServiceCollection AddLlamaSharpServices(this IServiceCollection services) + { + // Override IAiService — last registration wins in MS DI + services.AddSingleton(provider => + { + var config = provider.GetRequiredService().Current; + + if (string.Equals(config.AiProvider, "llamasharp", StringComparison.OrdinalIgnoreCase)) + { + try + { + return new LlamaSharpAiService(config); + } + catch (Exception ex) + { + return new DisabledAiService( + provider: "llamasharp", + message: $"LLamaSharp unavailable: {ex.Message}", + healthy: false); + } + } + + if (string.Equals(config.AiProvider, "python-sidecar", StringComparison.OrdinalIgnoreCase)) + { + try + { + return new PythonSidecarAiService(config); + } + catch (Exception ex) + { + return new DisabledAiService( + provider: "python-sidecar", + message: $"Python AI sidecar unavailable: {ex.Message}", + healthy: false); + } + } + + return new DisabledAiService(config.AiProvider); + }); + + // Register coach service + services.AddSingleton(provider => + { + var config = provider.GetRequiredService().Current; + var ai = provider.GetRequiredService(); + + if (ai is LlamaSharpAiService llamaAi) + return new LlamaSharpCoachService(llamaAi); + + if (string.Equals(config.AiProvider, "none", StringComparison.OrdinalIgnoreCase)) + return new DisabledCoachService(); + + return new DisabledCoachService( + $"Coach requires llamasharp provider (current: {config.AiProvider})."); + }); + + return services; + } +} diff --git a/Journal.AI/Weekly-Review.txt b/Journal.AI/Weekly-Review.txt new file mode 100644 index 0000000..8e1c52a --- /dev/null +++ b/Journal.AI/Weekly-Review.txt @@ -0,0 +1,19 @@ +{{CoachRules}} + +Task: +Generate a WEEKLY REVIEW for {{weekStartLocal}} to {{weekEndLocal}}. + +Context (JSON): +{{contextJson}} + +Preferences (JSON): +{{preferencesJson}} + +Output: +Return a CoachPlanDto JSON with kind="weekly_review". +Include: +- Themes (top 2-4) and blockers (top 1-3) +- Wins (top 1-3) +- 1-{{maxQuestions}} questions to set next week’s focus +- 2-{{maxNextActions}} suggestedNextActions +- patchProposal should only propose creating todos or a planning fragment, not editing old entries. \ No newline at end of file diff --git a/Journal.App/src/lib/backend/ai.ts b/Journal.App/src/lib/backend/ai.ts new file mode 100644 index 0000000..0614287 --- /dev/null +++ b/Journal.App/src/lib/backend/ai.ts @@ -0,0 +1,222 @@ +import { sendCommand } from "./client"; +import { pickCase } from "./normalize"; + +// ── Public types ──────────────────────────────────────────────── + +export type AiHealthDto = { + provider: string; + enabled: boolean; + healthy: boolean; + message: string; +}; + +export type CoachEvidenceDto = { + recordId: string | null; + text: string; +}; + +export type CoachPatchProposalDto = { + kind: string; + description: string | null; + content: string | null; +}; + +export type CoachPlanDto = { + kind: string; + title: string; + summary: string; + questions: string[]; + suggestedNextActions: string[]; + suggestedTags: string[]; + evidence: CoachEvidenceDto[]; + patchProposal: CoachPatchProposalDto | null; +}; + +export type CoachPreferencesDto = { + maxQuestions?: number; + maxNextActions?: number; +}; + +export type CoachSessionPayload = { + dateLocal?: string; + weekStartLocal?: string; + weekEndLocal?: string; + recentEntries?: string[]; + recentFragments?: string[]; + preferences?: CoachPreferencesDto; +}; + +// ── Raw (PascalCase) variants for normalization ───────────────── + +type AiHealthDtoRaw = { + provider?: string; + enabled?: boolean; + healthy?: boolean; + message?: string; + Provider?: string; + Enabled?: boolean; + Healthy?: boolean; + Message?: string; +}; + +type CoachEvidenceDtoRaw = { + recordId?: string | null; + text?: string; + RecordId?: string | null; + Text?: string; +}; + +type CoachPatchProposalDtoRaw = { + kind?: string; + description?: string | null; + content?: string | null; + Kind?: string; + Description?: string | null; + Content?: string | null; +}; + +type CoachPlanDtoRaw = { + kind?: string; + title?: string; + summary?: string; + questions?: string[]; + suggestedNextActions?: string[]; + suggestedTags?: string[]; + evidence?: CoachEvidenceDtoRaw[]; + patchProposal?: CoachPatchProposalDtoRaw | null; + Kind?: string; + Title?: string; + Summary?: string; + Questions?: string[]; + SuggestedNextActions?: string[]; + SuggestedTags?: string[]; + Evidence?: CoachEvidenceDtoRaw[]; + PatchProposal?: CoachPatchProposalDtoRaw | null; +}; + +// ── Normalizers ───────────────────────────────────────────────── + +function normalizeHealth(raw: AiHealthDtoRaw): AiHealthDto { + return { + provider: pickCase(raw, "provider", "Provider", ""), + enabled: pickCase(raw, "enabled", "Enabled", false), + healthy: pickCase(raw, "healthy", "Healthy", false), + message: pickCase(raw, "message", "Message", ""), + }; +} + +function normalizeEvidence(raw: CoachEvidenceDtoRaw): CoachEvidenceDto { + return { + recordId: pickCase(raw, "recordId", "RecordId", null as string | null), + text: pickCase(raw, "text", "Text", ""), + }; +} + +function normalizePatchProposal( + raw: CoachPatchProposalDtoRaw | null | undefined, +): CoachPatchProposalDto | null { + if (!raw) return null; + return { + kind: pickCase(raw, "kind", "Kind", ""), + description: pickCase( + raw, + "description", + "Description", + null as string | null, + ), + content: pickCase(raw, "content", "Content", null as string | null), + }; +} + +function normalizeCoachPlan(raw: CoachPlanDtoRaw): CoachPlanDto { + const evidenceRaw = pickCase( + raw, + "evidence", + "Evidence", + [] as CoachEvidenceDtoRaw[], + ); + const patchRaw = pickCase( + raw, + "patchProposal", + "PatchProposal", + null as CoachPatchProposalDtoRaw | null, + ); + + return { + kind: pickCase(raw, "kind", "Kind", ""), + title: pickCase(raw, "title", "Title", ""), + summary: pickCase(raw, "summary", "Summary", ""), + questions: pickCase(raw, "questions", "Questions", [] as string[]), + suggestedNextActions: pickCase( + raw, + "suggestedNextActions", + "SuggestedNextActions", + [] as string[], + ), + suggestedTags: pickCase( + raw, + "suggestedTags", + "SuggestedTags", + [] as string[], + ), + evidence: evidenceRaw.map(normalizeEvidence), + patchProposal: normalizePatchProposal(patchRaw), + }; +} + +// ── API functions ─────────────────────────────────────────────── + +export async function aiHealth(): Promise { + const data = await sendCommand({ + action: "ai.health", + payload: {}, + }); + return normalizeHealth(data); +} + +export async function aiChat(prompt: string): Promise { + return sendCommand({ + action: "ai.chat", + payload: { prompt }, + }); +} + +export async function aiSummarizeEntry( + content: string, + fileStem?: string, +): Promise { + return sendCommand({ + action: "ai.summarize_entry", + payload: { content, fileStem }, + }); +} + +export async function coachDaily( + payload: CoachSessionPayload = {}, +): Promise { + const data = await sendCommand({ + action: "ai.coach.daily", + payload, + }); + return normalizeCoachPlan(data); +} + +export async function coachEvening( + payload: CoachSessionPayload = {}, +): Promise { + const data = await sendCommand({ + action: "ai.coach.evening", + payload, + }); + return normalizeCoachPlan(data); +} + +export async function coachWeekly( + payload: CoachSessionPayload = {}, +): Promise { + const data = await sendCommand({ + action: "ai.coach.weekly", + payload, + }); + return normalizeCoachPlan(data); +} diff --git a/Journal.App/src/lib/backend/conversations.ts b/Journal.App/src/lib/backend/conversations.ts new file mode 100644 index 0000000..675ff28 --- /dev/null +++ b/Journal.App/src/lib/backend/conversations.ts @@ -0,0 +1,166 @@ +import { sendCommand } from "./client"; +import { pickCase } from "./normalize"; + +// ── Public types ──────────────────────────────────────────────── + +export type ConversationDto = { + id: string; + title: string; + createdAt: string; + updatedAt: string; +}; + +export type ConversationMessageDto = { + id: string; + role: string; + text: string; + createdAt: string; +}; + +export type ConversationDetailDto = { + id: string; + title: string; + messages: ConversationMessageDto[]; + createdAt: string; + updatedAt: string; +}; + +export type ConversationChatResult = { + userMessage: ConversationMessageDto; + assistantMessage: ConversationMessageDto; +}; + +// ── Raw (PascalCase) variants ─────────────────────────────────── + +type ConversationDtoRaw = { + id?: string; + title?: string; + createdAt?: string; + updatedAt?: string; + Id?: string; + Title?: string; + CreatedAt?: string; + UpdatedAt?: string; +}; + +type ConversationMessageDtoRaw = { + id?: string; + role?: string; + text?: string; + createdAt?: string; + Id?: string; + Role?: string; + Text?: string; + CreatedAt?: string; +}; + +type ConversationDetailDtoRaw = { + id?: string; + title?: string; + messages?: ConversationMessageDtoRaw[]; + createdAt?: string; + updatedAt?: string; + Id?: string; + Title?: string; + Messages?: ConversationMessageDtoRaw[]; + CreatedAt?: string; + UpdatedAt?: string; +}; + +type ConversationChatResultRaw = { + userMessage?: ConversationMessageDtoRaw; + assistantMessage?: ConversationMessageDtoRaw; + UserMessage?: ConversationMessageDtoRaw; + AssistantMessage?: ConversationMessageDtoRaw; +}; + +// ── Normalizers ───────────────────────────────────────────────── + +function normalizeMessage(raw: ConversationMessageDtoRaw): ConversationMessageDto { + return { + id: pickCase(raw, "id", "Id", ""), + role: pickCase(raw, "role", "Role", ""), + text: pickCase(raw, "text", "Text", ""), + createdAt: pickCase(raw, "createdAt", "CreatedAt", ""), + }; +} + +function normalizeConversation(raw: ConversationDtoRaw): ConversationDto { + return { + id: pickCase(raw, "id", "Id", ""), + title: pickCase(raw, "title", "Title", ""), + createdAt: pickCase(raw, "createdAt", "CreatedAt", ""), + updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", ""), + }; +} + +function normalizeDetail(raw: ConversationDetailDtoRaw): ConversationDetailDto { + const msgs = pickCase(raw, "messages", "Messages", [] as ConversationMessageDtoRaw[]); + return { + id: pickCase(raw, "id", "Id", ""), + title: pickCase(raw, "title", "Title", ""), + messages: msgs.map(normalizeMessage), + createdAt: pickCase(raw, "createdAt", "CreatedAt", ""), + updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", ""), + }; +} + +function normalizeChatResult(raw: ConversationChatResultRaw): ConversationChatResult { + const userRaw = pickCase(raw, "userMessage", "UserMessage", {} as ConversationMessageDtoRaw); + const assistantRaw = pickCase(raw, "assistantMessage", "AssistantMessage", {} as ConversationMessageDtoRaw); + return { + userMessage: normalizeMessage(userRaw), + assistantMessage: normalizeMessage(assistantRaw), + }; +} + +// ── API functions ─────────────────────────────────────────────── + +export async function listConversations(): Promise { + const data = await sendCommand({ + action: "conversations.list", + payload: {}, + }); + return (data ?? []).map(normalizeConversation); +} + +export async function getConversation(id: string): Promise { + const data = await sendCommand({ + action: "conversations.get", + id, + payload: {}, + }); + return normalizeDetail(data); +} + +export async function createConversation(title: string): Promise { + const data = await sendCommand({ + action: "conversations.create", + payload: { title }, + }); + return normalizeConversation(data); +} + +export async function updateConversation(id: string, title: string): Promise { + return sendCommand({ + action: "conversations.update", + id, + payload: { title }, + }); +} + +export async function deleteConversation(id: string): Promise { + return sendCommand({ + action: "conversations.delete", + id, + payload: {}, + }); +} + +export async function conversationChat(conversationId: string, prompt: string): Promise { + const data = await sendCommand({ + action: "conversations.chat", + payload: { conversationId, prompt }, + }); + return normalizeChatResult(data); +} diff --git a/Journal.App/src/lib/components/CoachPanel.svelte b/Journal.App/src/lib/components/CoachPanel.svelte new file mode 100644 index 0000000..135ff43 --- /dev/null +++ b/Journal.App/src/lib/components/CoachPanel.svelte @@ -0,0 +1,691 @@ + + + +
+ +
+ {#if healthChecking} + + Checking AI status… + {:else if health} + + + {health.provider || "AI"} — {health.healthy ? "Ready" : "Unavailable"} + + {#if health.message} + {health.message} + {/if} + {:else} + + AI status unknown + {/if} +
+ + + {#if coachBusy} +
+
+

Running {kindLabels[coachKind] ?? "coach session"}…

+

+ This may take a moment while the model generates a response. +

+
+ {:else if coachError} +
+ error +

{coachError}

+
+ {:else if coachPlan} +
+
+ {kindLabels[coachPlan.kind] ?? coachPlan.kind} +

{coachPlan.title}

+
+ + {#if coachPlan.summary} +
+

Summary

+

{coachPlan.summary}

+
+ {/if} + + {#if coachPlan.questions.length > 0} +
+

Reflection Questions

+
    + {#each coachPlan.questions as question} +
  1. {question}
  2. + {/each} +
+
+ {/if} + + {#if coachPlan.suggestedNextActions.length > 0} +
+

Suggested Next Actions

+
    + {#each coachPlan.suggestedNextActions as action} +
  • {action}
  • + {/each} +
+
+ {/if} + + {#if coachPlan.suggestedTags.length > 0} +
+

Suggested Tags

+
+ {#each coachPlan.suggestedTags as tag} + {tag} + {/each} +
+
+ {/if} + + {#if coachPlan.evidence.length > 0} +
+

Evidence

+
    + {#each coachPlan.evidence as item} +
  • + {item.text} + {#if item.recordId} + ({item.recordId}) + {/if} +
  • + {/each} +
+
+ {/if} + + {#if coachPlan.patchProposal} +
+

Patch Proposal

+
+ {coachPlan.patchProposal.kind} + {#if coachPlan.patchProposal.description} +

{coachPlan.patchProposal.description}

+ {/if} + {#if coachPlan.patchProposal.content} +
{coachPlan.patchProposal.content}
+ {/if} +
+
+ {/if} +
+ {:else if chatMessages.length === 0 && !chatBusy} +
+ psychology +

+ Select a coaching session from the sidebar, or ask a question using + the input below. +

+
+ {/if} + + + {#if chatMessages.length > 0} +
+

Conversation

+
+ {#each chatMessages as msg} +
+ {msg.role === "user" ? "You" : msg.role === "error" ? "Error" : "AI"} +
{msg.text}
+
+ {/each} + {#if chatBusy} +
+ AI +
+
+ Thinking… +
+
+ {/if} +
+
+ {:else if chatBusy} +
+
+

Thinking…

+
+ {/if} + + +
+
+ + +
+ {#if chatMessages.length > 0} + + {/if} +
+
+ + diff --git a/Journal.App/src/lib/components/EditorPanel.svelte b/Journal.App/src/lib/components/EditorPanel.svelte index e890f27..7f714b8 100644 --- a/Journal.App/src/lib/components/EditorPanel.svelte +++ b/Journal.App/src/lib/components/EditorPanel.svelte @@ -4,6 +4,8 @@ import ListEditor from "$lib/components/editor/ListEditor.svelte"; import TodoEditor from "$lib/components/editor/TodoEditor.svelte"; import MarkdownEditor from "$lib/components/editor/MarkdownEditor.svelte"; + import CoachPanel from "$lib/components/CoachPanel.svelte"; + import { aiStatusStore, coachStateStore } from "$lib/stores/ai"; export let activeSection = "entries"; export let openDocumentId = "entries/daily-notes"; @@ -154,6 +156,15 @@ {/if} + {:else if activeSection === "coach"} + {:else if !openDocumentId}
edit_note diff --git a/Journal.App/src/lib/components/Navbar.svelte b/Journal.App/src/lib/components/Navbar.svelte index 1141680..f33b41d 100644 --- a/Journal.App/src/lib/components/Navbar.svelte +++ b/Journal.App/src/lib/components/Navbar.svelte @@ -15,6 +15,7 @@ { id: "fragments", label: "Fragments", icon: "auto_stories" }, { id: "todos", label: "To-Do List", icon: "checklist" }, { id: "lists", label: "Lists", icon: "lists" }, + { id: "coach", label: "Coach", icon: "psychology" }, ]; function selectItem(id: string) { diff --git a/Journal.App/src/lib/components/SidePanel.svelte b/Journal.App/src/lib/components/SidePanel.svelte index 3b9d0c4..79fcc15 100644 --- a/Journal.App/src/lib/components/SidePanel.svelte +++ b/Journal.App/src/lib/components/SidePanel.svelte @@ -32,6 +32,24 @@ } from "$lib/stores/todos"; import { vaultUnlocked } from "$lib/stores/session"; import { extractEntryTags } from "$lib/utils/metadata"; + import { + aiStatusStore, + coachStateStore, + checkAiHealth, + runCoachSession, + clearCoachPlan, + } from "$lib/stores/ai"; + import type { CoachSessionPayload } from "$lib/backend/ai"; + import { + conversationsStore, + activeConversationStore, + loadConversations, + createNewConversation, + openConversation, + renameConversation, + removeConversation, + clearActiveConversation, + } from "$lib/stores/conversations"; export let activeSection = "entries"; export let activeDocumentId = ""; @@ -94,8 +112,90 @@ fragments: "Fragments", todos: "To-Do List", lists: "Lists", + coach: "Coach", }; + // ── Coach state ────────────────────────────────── + let coachHealthChecked = false; + let conversationsLoaded = false; + let editingConversationId = ""; + let editingConversationTitle = ""; + + const MAX_CONTEXT_ENTRIES = 5; + const MAX_CONTEXT_FRAGMENTS = 10; + + function gatherCoachContext(): CoachSessionPayload { + const now = new Date(); + const dateLocal = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`; + const recentEntries = $entriesStore + .filter((e) => e.initialContent?.trim()) + .slice(0, MAX_CONTEXT_ENTRIES) + .map((e) => e.initialContent); + const recentFragments = $fragmentsStore + .filter((f) => f.initialContent?.trim()) + .slice(0, MAX_CONTEXT_FRAGMENTS) + .map((f) => f.initialContent); + return { dateLocal, recentEntries, recentFragments }; + } + + function handleCoachSession(kind: string) { + const payload = gatherCoachContext(); + if (kind === "weekly") { + const now = new Date(); + const weekDay = (now.getDay() + 6) % 7; + const start = new Date(now); + start.setDate(now.getDate() - weekDay); + const end = new Date(start); + end.setDate(start.getDate() + 6); + payload.weekStartLocal = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, "0")}-${String(start.getDate()).padStart(2, "0")}`; + payload.weekEndLocal = `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, "0")}-${String(end.getDate()).padStart(2, "0")}`; + } + void runCoachSession(kind, payload); + } + + // ── Conversation helpers ────────────────────────── + function ensureConversationsLoaded() { + if (!conversationsLoaded) { + conversationsLoaded = true; + void loadConversations(); + } + } + + $: if (activeSection === "coach") { + ensureConversationsLoaded(); + } + + function formatConversationDefaultTitle(): string { + const now = new Date(); + return now.toLocaleString(undefined, { + month: "short", + day: "numeric", + year: "numeric", + hour: "numeric", + minute: "2-digit", + }); + } + + function startRenamingConversation(id: string, currentTitle: string) { + editingConversationId = id; + editingConversationTitle = currentTitle; + } + + function commitRename() { + const trimmed = editingConversationTitle.trim(); + if (trimmed && editingConversationId) { + void renameConversation(editingConversationId, trimmed); + } + editingConversationId = ""; + editingConversationTitle = ""; + } + + function cancelRename() { + editingConversationId = ""; + editingConversationTitle = ""; + } + + const today = new Date(); let calendarYear = today.getFullYear(); let calendarMonth = today.getMonth(); @@ -938,15 +1038,17 @@ calendar_month {/if} - + {#if activeSection !== "coach"} + + {/if}
@@ -1085,6 +1187,145 @@ Last refreshed: {calendarLastRefreshedAt || "Not yet"}

+ {:else if activeSection === "coach"} +
+
+ +
+ +
+

Coaching Sessions

+
+ + + +
+ {#if $coachStateStore.plan} + + {/if} +
+ +
+
+

Conversations

+ +
+ + {#if $conversationsStore.busy} +

Loading…

+ {:else if $conversationsStore.items.length === 0} +

No conversations yet.

+ {:else} +
    + {#each $conversationsStore.items as conv} +
  • + {#if editingConversationId === conv.id} + { + if (e.key === "Enter") commitRename(); + if (e.key === "Escape") cancelRename(); + }} + on:blur={commitRename} + autofocus + /> + {:else} + +
    + + +
    + {/if} +
  • + {/each} +
+ {/if} +
+
{:else}