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 <oz-agent@warp.dev>
This commit is contained in:
Jacob Schmidt 2026-03-01 16:07:59 -06:00
parent ee96c05d15
commit 192e6e3891
47 changed files with 3225 additions and 236 deletions

View File

@ -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": "<short title>",
"summary": "<2-4 sentence summary>",
"questions": ["<question>"],
"suggestedNextActions": ["<action>"],
"suggestedTags": ["<tag>"],
"evidence": [{"recordId": null, "text": "<snippet>"}],
"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": "<why>", "content": "<markdown>"}.
Do NOT confuse patchProposal.kind with the top-level kind field.

View File

@ -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.

View File

@ -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.

View File

@ -0,0 +1,25 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="LLamaSharp" Version="0.26.0" />
<PackageReference Include="LLamaSharp.Backend.Cpu" Version="0.26.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\Journal.Core\Journal.Core.csproj" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="Coach-Rules.txt" />
<EmbeddedResource Include="Daily-Check-In.txt" />
<EmbeddedResource Include="Evening-Review.txt" />
<EmbeddedResource Include="Weekly-Review.txt" />
</ItemGroup>
</Project>

View File

@ -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<AiHealthDto> 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<string> 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<string> 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<string> 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<string> 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<string> SummarizeAllAsync(IReadOnlyList<string> 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<IReadOnlyList<double>> 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<double>();
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<string> 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<string> 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());
}
/// <summary>Strips only Phi-3 special tokens — safe for JSON output.</summary>
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();
/// <summary>Aggressive cleanup for conversational (non-JSON) responses.</summary>
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();
}

View File

@ -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<CoachPlanDto> 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<CoachPlanDto> 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<CoachPlanDto> 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<CoachPlanDto> 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<CoachPlanDto>(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();
}
}

View File

@ -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
{
/// <summary>
/// Registers LLamaSharp-based AI and Coach services.
/// Call this AFTER <c>AddFragmentServices()</c> so that <c>IJournalConfigService</c> is available.
/// When the provider is "llamasharp", this replaces the default <c>IAiService</c> registration.
/// </summary>
public static IServiceCollection AddLlamaSharpServices(this IServiceCollection services)
{
// Override IAiService — last registration wins in MS DI
services.AddSingleton<IAiService>(provider =>
{
var config = provider.GetRequiredService<IJournalConfigService>().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<ICoachService>(provider =>
{
var config = provider.GetRequiredService<IJournalConfigService>().Current;
var ai = provider.GetRequiredService<IAiService>();
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;
}
}

View File

@ -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 weeks focus
- 2-{{maxNextActions}} suggestedNextActions
- patchProposal should only propose creating todos or a planning fragment, not editing old entries.

View File

@ -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<AiHealthDto> {
const data = await sendCommand<AiHealthDtoRaw>({
action: "ai.health",
payload: {},
});
return normalizeHealth(data);
}
export async function aiChat(prompt: string): Promise<string> {
return sendCommand<string>({
action: "ai.chat",
payload: { prompt },
});
}
export async function aiSummarizeEntry(
content: string,
fileStem?: string,
): Promise<string> {
return sendCommand<string>({
action: "ai.summarize_entry",
payload: { content, fileStem },
});
}
export async function coachDaily(
payload: CoachSessionPayload = {},
): Promise<CoachPlanDto> {
const data = await sendCommand<CoachPlanDtoRaw>({
action: "ai.coach.daily",
payload,
});
return normalizeCoachPlan(data);
}
export async function coachEvening(
payload: CoachSessionPayload = {},
): Promise<CoachPlanDto> {
const data = await sendCommand<CoachPlanDtoRaw>({
action: "ai.coach.evening",
payload,
});
return normalizeCoachPlan(data);
}
export async function coachWeekly(
payload: CoachSessionPayload = {},
): Promise<CoachPlanDto> {
const data = await sendCommand<CoachPlanDtoRaw>({
action: "ai.coach.weekly",
payload,
});
return normalizeCoachPlan(data);
}

View File

@ -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<ConversationDto[]> {
const data = await sendCommand<ConversationDtoRaw[]>({
action: "conversations.list",
payload: {},
});
return (data ?? []).map(normalizeConversation);
}
export async function getConversation(id: string): Promise<ConversationDetailDto> {
const data = await sendCommand<ConversationDetailDtoRaw>({
action: "conversations.get",
id,
payload: {},
});
return normalizeDetail(data);
}
export async function createConversation(title: string): Promise<ConversationDto> {
const data = await sendCommand<ConversationDtoRaw>({
action: "conversations.create",
payload: { title },
});
return normalizeConversation(data);
}
export async function updateConversation(id: string, title: string): Promise<boolean> {
return sendCommand<boolean>({
action: "conversations.update",
id,
payload: { title },
});
}
export async function deleteConversation(id: string): Promise<boolean> {
return sendCommand<boolean>({
action: "conversations.delete",
id,
payload: {},
});
}
export async function conversationChat(conversationId: string, prompt: string): Promise<ConversationChatResult> {
const data = await sendCommand<ConversationChatResultRaw>({
action: "conversations.chat",
payload: { conversationId, prompt },
});
return normalizeChatResult(data);
}

View File

@ -0,0 +1,691 @@
<!-- @format -->
<script lang="ts">
import type { AiHealthDto, CoachPlanDto } from "$lib/backend/ai";
import {
activeConversationStore,
sendConversationMessage,
clearActiveConversation,
} from "$lib/stores/conversations";
export let health: AiHealthDto | null = null;
export let healthChecking = false;
export let coachBusy = false;
export let coachError = "";
export let coachPlan: CoachPlanDto | null = null;
export let coachKind = "";
$: chatBusy = $activeConversationStore.busy;
$: chatMessages = $activeConversationStore.messages;
let chatInput = "";
function handleChatSubmit() {
const prompt = chatInput.trim();
if (!prompt) return;
chatInput = "";
void sendConversationMessage(prompt);
}
function handleChatKeydown(event: KeyboardEvent) {
if (event.key === "Enter" && !event.shiftKey) {
event.preventDefault();
handleChatSubmit();
}
}
const kindLabels: Record<string, string> = {
daily: "Daily Check-In",
daily_checkin: "Daily Check-In",
evening: "Evening Review",
evening_review: "Evening Review",
weekly: "Weekly Review",
weekly_review: "Weekly Review",
};
</script>
<div class="coach-panel">
<!-- Health banner -->
<div
class="health-banner"
class:is-healthy={health?.healthy}
class:is-unhealthy={health && !health.healthy}
>
{#if healthChecking}
<span class="health-dot checking"></span>
<span class="health-label">Checking AI status…</span>
{:else if health}
<span
class="health-dot"
class:healthy={health.healthy}
class:unhealthy={!health.healthy}
></span>
<span class="health-label">
{health.provider || "AI"}{health.healthy ? "Ready" : "Unavailable"}
</span>
{#if health.message}
<span class="health-message">{health.message}</span>
{/if}
{:else}
<span class="health-dot unknown"></span>
<span class="health-label">AI status unknown</span>
{/if}
</div>
<!-- Coach result -->
{#if coachBusy}
<div class="coach-loading">
<div class="spinner"></div>
<p>Running {kindLabels[coachKind] ?? "coach session"}</p>
<p class="loading-hint">
This may take a moment while the model generates a response.
</p>
</div>
{:else if coachError}
<div class="coach-error">
<span class="material-symbols-outlined error-icon">error</span>
<p>{coachError}</p>
</div>
{:else if coachPlan}
<article class="coach-plan">
<header class="plan-header">
<span class="plan-kind"
>{kindLabels[coachPlan.kind] ?? coachPlan.kind}</span
>
<h2>{coachPlan.title}</h2>
</header>
{#if coachPlan.summary}
<section class="plan-section">
<h3>Summary</h3>
<p>{coachPlan.summary}</p>
</section>
{/if}
{#if coachPlan.questions.length > 0}
<section class="plan-section">
<h3>Reflection Questions</h3>
<ol class="plan-list">
{#each coachPlan.questions as question}
<li>{question}</li>
{/each}
</ol>
</section>
{/if}
{#if coachPlan.suggestedNextActions.length > 0}
<section class="plan-section">
<h3>Suggested Next Actions</h3>
<ul class="plan-list">
{#each coachPlan.suggestedNextActions as action}
<li>{action}</li>
{/each}
</ul>
</section>
{/if}
{#if coachPlan.suggestedTags.length > 0}
<section class="plan-section">
<h3>Suggested Tags</h3>
<div class="tag-list">
{#each coachPlan.suggestedTags as tag}
<span class="tag">{tag}</span>
{/each}
</div>
</section>
{/if}
{#if coachPlan.evidence.length > 0}
<section class="plan-section">
<h3>Evidence</h3>
<ul class="evidence-list">
{#each coachPlan.evidence as item}
<li>
<span class="evidence-text">{item.text}</span>
{#if item.recordId}
<span class="evidence-ref">({item.recordId})</span>
{/if}
</li>
{/each}
</ul>
</section>
{/if}
{#if coachPlan.patchProposal}
<section class="plan-section">
<h3>Patch Proposal</h3>
<div class="patch-proposal">
<span class="patch-kind">{coachPlan.patchProposal.kind}</span>
{#if coachPlan.patchProposal.description}
<p>{coachPlan.patchProposal.description}</p>
{/if}
{#if coachPlan.patchProposal.content}
<pre class="patch-content">{coachPlan.patchProposal.content}</pre>
{/if}
</div>
</section>
{/if}
</article>
{:else if chatMessages.length === 0 && !chatBusy}
<div class="coach-empty">
<span class="material-symbols-outlined empty-icon">psychology</span>
<p>
Select a coaching session from the sidebar, or ask a question using
the input below.
</p>
</div>
{/if}
<!-- Chat conversation -->
{#if chatMessages.length > 0}
<section class="chat-history">
<h3>Conversation</h3>
<div class="chat-messages">
{#each chatMessages as msg}
<div class="chat-msg" class:chat-user={msg.role === "user"} class:chat-assistant={msg.role === "assistant"} class:chat-error={msg.role === "error"}>
<span class="chat-role">{msg.role === "user" ? "You" : msg.role === "error" ? "Error" : "AI"}</span>
<div class="chat-text">{msg.text}</div>
</div>
{/each}
{#if chatBusy}
<div class="chat-msg chat-assistant">
<span class="chat-role">AI</span>
<div class="chat-thinking">
<div class="spinner-sm"></div>
<span>Thinking…</span>
</div>
</div>
{/if}
</div>
</section>
{:else if chatBusy}
<div class="chat-loading">
<div class="spinner"></div>
<p>Thinking…</p>
</div>
{/if}
<!-- Chat input (pinned at bottom) -->
<div class="chat-input-bar">
<div class="chat-input-row">
<input
type="text"
bind:value={chatInput}
placeholder="Ask a question…"
on:keydown={handleChatKeydown}
disabled={chatBusy}
/>
<button
type="button"
class="chat-send-btn"
on:click={handleChatSubmit}
disabled={chatBusy || !chatInput.trim()}
aria-label="Send"
>
<span class="material-symbols-outlined">send</span>
</button>
</div>
{#if chatMessages.length > 0}
<button type="button" class="chat-clear-btn" on:click={clearActiveConversation}>
Clear Chat
</button>
{/if}
</div>
</div>
<style>
.coach-panel {
display: flex;
flex-direction: column;
gap: 20px;
padding: 24px 24px 0;
overflow: auto;
min-height: 0;
flex: 1;
}
/* ── Health banner ───────────────────────────────── */
.health-banner {
display: flex;
align-items: center;
gap: 8px;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--border-soft);
background: var(--surface-1);
font-size: 0.82rem;
}
.health-dot {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
&.healthy {
background: #4ade80;
}
&.unhealthy {
background: #e06c75;
}
&.checking {
background: var(--text-dim);
animation: pulse 1s ease-in-out infinite;
}
&.unknown {
background: var(--text-dim);
}
}
.health-label {
color: var(--text-primary);
font-weight: 500;
}
.health-message {
color: var(--text-dim);
margin-left: auto;
}
/* ── Coach plan ──────────────────────────────────── */
.coach-plan {
display: flex;
flex-direction: column;
gap: 18px;
}
.plan-header {
display: flex;
flex-direction: column;
gap: 4px;
h2 {
font-size: 1.15rem;
font-weight: 600;
color: var(--text-primary);
}
}
.plan-kind {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.06em;
color: var(--text-dim);
}
.plan-section {
display: flex;
flex-direction: column;
gap: 8px;
h3 {
font-size: 0.82rem;
font-weight: 600;
color: var(--text-muted);
letter-spacing: 0.01em;
}
p {
font-size: 0.88rem;
color: var(--text-primary);
line-height: 1.55;
}
}
.plan-list {
display: flex;
flex-direction: column;
gap: 6px;
padding-left: 20px;
li {
font-size: 0.86rem;
color: var(--text-primary);
line-height: 1.5;
}
}
.tag-list {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.tag {
padding: 3px 10px;
border-radius: 20px;
border: 1px solid var(--border-soft);
background: var(--surface-2);
font-size: 0.76rem;
color: var(--text-muted);
}
.evidence-list {
display: flex;
flex-direction: column;
gap: 6px;
list-style: none;
li {
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--surface-1);
font-size: 0.84rem;
line-height: 1.45;
}
}
.evidence-text {
color: var(--text-primary);
}
.evidence-ref {
color: var(--text-dim);
font-size: 0.76rem;
margin-left: 6px;
}
.patch-proposal {
padding: 12px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--surface-1);
display: flex;
flex-direction: column;
gap: 8px;
p {
font-size: 0.84rem;
color: var(--text-primary);
line-height: 1.5;
}
}
.patch-kind {
font-size: 0.72rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
}
.patch-content {
font-family: "Cascadia Code", "Fira Code", monospace;
font-size: 0.8rem;
color: var(--text-muted);
background: var(--surface-2);
padding: 10px 12px;
border-radius: 6px;
border: 1px solid var(--border-soft);
overflow-x: auto;
white-space: pre-wrap;
word-break: break-word;
}
/* ── Chat conversation ────────────────────────────── */
.chat-history {
display: flex;
flex-direction: column;
gap: 10px;
border-top: 1px solid var(--border-soft);
padding-top: 18px;
h3 {
font-size: 0.82rem;
font-weight: 600;
color: var(--text-muted);
}
}
.chat-messages {
display: flex;
flex-direction: column;
gap: 8px;
}
.chat-msg {
display: flex;
flex-direction: column;
gap: 4px;
padding: 10px 14px;
border-radius: 10px;
border: 1px solid var(--border-soft);
}
.chat-user {
background: var(--surface-2);
align-self: flex-end;
max-width: 85%;
}
.chat-assistant {
background: var(--surface-1);
align-self: flex-start;
max-width: 85%;
}
.chat-error {
background: rgba(224, 108, 117, 0.08);
border-color: #e06c75;
align-self: flex-start;
max-width: 85%;
}
.chat-role {
font-size: 0.7rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-dim);
}
.chat-error .chat-role {
color: #e06c75;
}
.chat-text {
font-size: 0.86rem;
color: var(--text-primary);
line-height: 1.55;
white-space: pre-wrap;
word-break: break-word;
}
.chat-thinking {
display: flex;
align-items: center;
gap: 8px;
font-size: 0.82rem;
color: var(--text-dim);
}
.spinner-sm {
width: 16px;
height: 16px;
border: 2px solid var(--border-soft);
border-top-color: var(--text-muted);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
/* ── Empty / loading / error ─────────────────────── */
.coach-empty {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 10px;
padding: 60px 20px;
text-align: center;
.empty-icon {
font-size: 2.8rem;
color: var(--text-dim);
}
p {
font-size: 0.88rem;
color: var(--text-dim);
max-width: 320px;
}
}
.coach-loading,
.chat-loading {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
padding: 40px 20px;
text-align: center;
p {
font-size: 0.88rem;
color: var(--text-muted);
}
}
.loading-hint {
font-size: 0.78rem !important;
color: var(--text-dim) !important;
}
.coach-error {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 12px 14px;
border-radius: 10px;
border: 1px solid #e06c75;
background: rgba(224, 108, 117, 0.08);
.error-icon {
color: #e06c75;
font-size: 1.1rem;
flex-shrink: 0;
margin-top: 1px;
}
p {
font-size: 0.84rem;
color: var(--text-primary);
line-height: 1.45;
}
}
.spinner {
width: 28px;
height: 28px;
border: 3px solid var(--border-soft);
border-top-color: var(--text-muted);
border-radius: 50%;
animation: spin 0.7s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
@keyframes pulse {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
/* ── Chat input bar (pinned bottom) ─────────────── */
.chat-input-bar {
position: sticky;
bottom: 0;
display: flex;
flex-direction: column;
gap: 6px;
padding: 12px 0 24px;
background: var(--bg-editor);
margin-top: auto;
}
.chat-input-row {
display: flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border-soft);
border-radius: 10px;
background: var(--surface-1);
padding: 6px 8px 6px 14px;
input {
flex: 1;
min-width: 0;
font-size: 0.86rem;
color: var(--text-primary);
&::placeholder {
color: var(--text-dim);
}
&:disabled {
opacity: 0.5;
}
}
}
.chat-send-btn {
width: 32px;
height: 32px;
display: grid;
place-items: center;
border-radius: 8px;
color: var(--text-muted);
cursor: pointer;
border: 1px solid transparent;
flex-shrink: 0;
.material-symbols-outlined {
font-size: 1rem;
}
&:hover {
background: var(--bg-hover);
border-color: var(--border-soft);
color: var(--text-primary);
}
&:disabled {
opacity: 0.4;
cursor: default;
}
}
.chat-clear-btn {
align-self: flex-start;
padding: 4px 10px;
border-radius: 6px;
border: 1px solid var(--border-soft);
background: transparent;
color: var(--text-dim);
font-size: 0.74rem;
cursor: pointer;
&:hover {
color: var(--text-primary);
border-color: var(--border-strong);
background: var(--bg-hover);
}
}
</style>

View File

@ -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 @@
</ul>
{/if}
</section>
{:else if activeSection === "coach"}
<CoachPanel
health={$aiStatusStore.health}
healthChecking={$aiStatusStore.checking}
coachBusy={$coachStateStore.busy}
coachError={$coachStateStore.error}
coachPlan={$coachStateStore.plan}
coachKind={$coachStateStore.kind}
/>
{:else if !openDocumentId}
<div class="editor-empty">
<span class="material-symbols-outlined empty-icon">edit_note</span>

View File

@ -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) {

View File

@ -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 @@
<span class="material-symbols-outlined">calendar_month</span>
</button>
{/if}
<button
type="button"
class="panel-action"
aria-label="Add item"
title="Add item"
on:click={handleAddItem}
>
<span class="material-symbols-outlined">add</span>
</button>
{#if activeSection !== "coach"}
<button
type="button"
class="panel-action"
aria-label="Add item"
title="Add item"
on:click={handleAddItem}
>
<span class="material-symbols-outlined">add</span>
</button>
{/if}
</div>
</header>
@ -1085,6 +1187,145 @@
Last refreshed: {calendarLastRefreshedAt || "Not yet"}
</p>
</div>
{:else if activeSection === "coach"}
<div class="coach-sidebar">
<div class="coach-health">
<button
type="button"
class="coach-health-btn"
on:click={() => void checkAiHealth()}
disabled={$aiStatusStore.checking}
>
<span
class="health-indicator"
class:healthy={$aiStatusStore.health?.healthy}
class:unhealthy={$aiStatusStore.health &&
!$aiStatusStore.health.healthy}
class:checking={$aiStatusStore.checking}
></span>
{$aiStatusStore.checking
? "Checking…"
: $aiStatusStore.health
? $aiStatusStore.health.healthy
? "AI Ready"
: "AI Unavailable"
: "Check AI Status"}
</button>
</div>
<div class="panel-subsection">
<h3>Coaching Sessions</h3>
<div class="coach-session-buttons">
<button
type="button"
class="coach-session-btn"
on:click={() => handleCoachSession("daily")}
disabled={$coachStateStore.busy}
>
<span class="material-symbols-outlined">wb_sunny</span>
Daily Check-In
</button>
<button
type="button"
class="coach-session-btn"
on:click={() => handleCoachSession("evening")}
disabled={$coachStateStore.busy}
>
<span class="material-symbols-outlined">dark_mode</span>
Evening Review
</button>
<button
type="button"
class="coach-session-btn"
on:click={() => handleCoachSession("weekly")}
disabled={$coachStateStore.busy}
>
<span class="material-symbols-outlined">date_range</span>
Weekly Review
</button>
</div>
{#if $coachStateStore.plan}
<button
type="button"
class="coach-clear-btn"
on:click={clearCoachPlan}
>
Clear Result
</button>
{/if}
</div>
<div class="panel-subsection">
<div class="subsection-header">
<h3>Conversations</h3>
<button
type="button"
class="subsection-action"
on:click={() => {
clearActiveConversation();
void createNewConversation(formatConversationDefaultTitle());
}}
aria-label="New conversation"
>
<span class="material-symbols-outlined">add</span>
</button>
</div>
{#if $conversationsStore.busy}
<p class="section-copy">Loading…</p>
{:else if $conversationsStore.items.length === 0}
<p class="section-copy">No conversations yet.</p>
{:else}
<ul class="panel-list">
{#each $conversationsStore.items as conv}
<li class:is-active={conv.id === $activeConversationStore.id}>
{#if editingConversationId === conv.id}
<input
type="text"
class="rename-input"
bind:value={editingConversationTitle}
on:keydown={(e) => {
if (e.key === "Enter") commitRename();
if (e.key === "Escape") cancelRename();
}}
on:blur={commitRename}
autofocus
/>
{:else}
<button
type="button"
class="item-label"
on:click={() => void openConversation(conv.id)}
>
{conv.title}
</button>
<div class="item-actions">
<button
type="button"
class="item-action"
on:click|stopPropagation={() =>
startRenamingConversation(conv.id, conv.title)}
aria-label="Rename conversation"
>
<span class="material-symbols-outlined">edit</span>
</button>
<button
type="button"
class="item-action item-action-danger"
on:click|stopPropagation={() =>
void removeConversation(conv.id)}
aria-label="Delete conversation"
>
<span class="material-symbols-outlined">delete</span>
</button>
</div>
{/if}
</li>
{/each}
</ul>
{/if}
</div>
</div>
{:else}
<div class="panel-search">
<span class="material-symbols-outlined">search</span>
@ -1597,4 +1838,166 @@
grid-template-columns: 1fr;
}
}
/* ── Coach sidebar ────────────────────────────────── */
.coach-sidebar {
display: flex;
flex-direction: column;
gap: 16px;
overflow: auto;
min-height: 0;
}
.coach-health {
display: flex;
}
.coach-health-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 8px 12px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--surface-1);
color: var(--text-muted);
font-size: 0.8rem;
cursor: pointer;
&:hover {
background: var(--bg-hover);
border-color: var(--border-strong);
color: var(--text-primary);
}
&:disabled {
opacity: 0.6;
cursor: default;
}
}
.health-indicator {
width: 8px;
height: 8px;
border-radius: 50%;
flex-shrink: 0;
background: var(--text-dim);
&.healthy {
background: #4ade80;
}
&.unhealthy {
background: #e06c75;
}
&.checking {
animation: coach-pulse 1s ease-in-out infinite;
}
}
.coach-session-buttons {
display: flex;
flex-direction: column;
gap: 4px;
}
.coach-session-btn {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
padding: 9px 12px;
border-radius: 8px;
border: 1px solid var(--border-soft);
background: var(--surface-1);
color: var(--text-muted);
font-size: 0.82rem;
cursor: pointer;
text-align: left;
.material-symbols-outlined {
font-size: 1rem;
}
&:hover {
background: var(--bg-hover);
border-color: var(--border-strong);
color: var(--text-primary);
}
&:disabled {
opacity: 0.5;
cursor: default;
}
}
.coach-clear-btn {
align-self: flex-start;
padding: 4px 10px;
border-radius: 6px;
border: 1px solid var(--border-soft);
background: transparent;
color: var(--text-dim);
font-size: 0.74rem;
cursor: pointer;
&:hover {
color: var(--text-primary);
border-color: var(--border-strong);
background: var(--bg-hover);
}
}
.subsection-header {
display: flex;
align-items: center;
justify-content: space-between;
}
.subsection-action {
width: 26px;
height: 26px;
display: grid;
place-items: center;
border-radius: 6px;
border: 1px solid transparent;
background: transparent;
color: var(--text-dim);
cursor: pointer;
.material-symbols-outlined {
font-size: 1rem;
}
&:hover {
color: var(--text-primary);
border-color: var(--border-soft);
background: var(--bg-hover);
}
}
.rename-input {
width: 100%;
font-size: 0.82rem;
color: var(--text-primary);
background: var(--surface-1);
border: 1px solid var(--accent, #6b8afd);
border-radius: 6px;
padding: 6px 9px;
outline: none;
}
@keyframes coach-pulse {
0%,
100% {
opacity: 0.4;
}
50% {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,140 @@
import { writable } from "svelte/store";
import {
aiHealth as aiHealthCommand,
aiChat as aiChatCommand,
coachDaily,
coachEvening,
coachWeekly,
type AiHealthDto,
type CoachPlanDto,
type CoachSessionPayload,
} from "$lib/backend/ai";
// ── Store shapes ────────────────────────────────────────────────
type AiStatusState = {
checking: boolean;
health: AiHealthDto | null;
};
type CoachState = {
busy: boolean;
error: string;
plan: CoachPlanDto | null;
kind: string;
};
export type ChatMessage = {
role: "user" | "assistant" | "error";
text: string;
};
type ChatState = {
busy: boolean;
messages: ChatMessage[];
};
// ── Stores ──────────────────────────────────────────────────────
export const aiStatusStore = writable<AiStatusState>({
checking: false,
health: null,
});
export const coachStateStore = writable<CoachState>({
busy: false,
error: "",
plan: null,
kind: "",
});
export const chatStateStore = writable<ChatState>({
busy: false,
messages: [],
});
// ── Actions ─────────────────────────────────────────────────────
export async function checkAiHealth(): Promise<void> {
aiStatusStore.update((s) => ({ ...s, checking: true }));
try {
const health = await aiHealthCommand();
aiStatusStore.set({ checking: false, health });
} catch (error) {
aiStatusStore.set({
checking: false,
health: {
provider: "",
enabled: false,
healthy: false,
message: error instanceof Error ? error.message : String(error),
},
});
}
}
const sessionRunners: Record<
string,
(payload: CoachSessionPayload) => Promise<CoachPlanDto>
> = {
daily: coachDaily,
evening: coachEvening,
weekly: coachWeekly,
};
export async function runCoachSession(
kind: string,
payload: CoachSessionPayload = {},
): Promise<void> {
const runner = sessionRunners[kind];
if (!runner) {
coachStateStore.set({
busy: false,
error: `Unknown coach session kind: ${kind}`,
plan: null,
kind,
});
return;
}
coachStateStore.set({ busy: true, error: "", plan: null, kind });
try {
const plan = await runner(payload);
coachStateStore.set({ busy: false, error: "", plan, kind });
} catch (error) {
coachStateStore.set({
busy: false,
error: error instanceof Error ? error.message : String(error),
plan: null,
kind,
});
}
}
export async function runAiChat(prompt: string): Promise<void> {
chatStateStore.update((s) => ({
busy: true,
messages: [...s.messages, { role: "user", text: prompt }],
}));
try {
const response = await aiChatCommand(prompt);
chatStateStore.update((s) => ({
busy: false,
messages: [...s.messages, { role: "assistant", text: response }],
}));
} catch (error) {
const msg = error instanceof Error ? error.message : String(error);
chatStateStore.update((s) => ({
busy: false,
messages: [...s.messages, { role: "error", text: msg }],
}));
}
}
export function clearCoachPlan(): void {
coachStateStore.set({ busy: false, error: "", plan: null, kind: "" });
}
export function clearChat(): void {
chatStateStore.set({ busy: false, messages: [] });
}

View File

@ -0,0 +1,222 @@
import { writable, get } from "svelte/store";
import {
listConversations as listConversationsApi,
getConversation as getConversationApi,
createConversation as createConversationApi,
deleteConversation as deleteConversationApi,
updateConversation as updateConversationApi,
conversationChat as conversationChatApi,
type ConversationDto,
type ConversationMessageDto,
} from "$lib/backend/conversations";
// ── Store shapes ────────────────────────────────────────────────
type ConversationsState = {
items: ConversationDto[];
busy: boolean;
error: string;
};
type ActiveConversationState = {
id: string;
title: string;
messages: ConversationMessageDto[];
busy: boolean;
error: string;
};
// ── Stores ──────────────────────────────────────────────────────
export const conversationsStore = writable<ConversationsState>({
items: [],
busy: false,
error: "",
});
export const activeConversationStore = writable<ActiveConversationState>({
id: "",
title: "",
messages: [],
busy: false,
error: "",
});
// ── Actions ─────────────────────────────────────────────────────
export async function loadConversations(): Promise<void> {
conversationsStore.update((s) => ({ ...s, busy: true, error: "" }));
try {
const items = await listConversationsApi();
conversationsStore.set({ items, busy: false, error: "" });
} catch (error) {
conversationsStore.update((s) => ({
...s,
busy: false,
error: error instanceof Error ? error.message : String(error),
}));
}
}
export async function createNewConversation(
title: string,
): Promise<string | null> {
try {
const conv = await createConversationApi(title);
conversationsStore.update((s) => ({
...s,
items: [conv, ...s.items],
}));
// Auto-open the new conversation
activeConversationStore.set({
id: conv.id,
title: conv.title,
messages: [],
busy: false,
error: "",
});
return conv.id;
} catch (error) {
conversationsStore.update((s) => ({
...s,
error: error instanceof Error ? error.message : String(error),
}));
return null;
}
}
export async function openConversation(id: string): Promise<void> {
activeConversationStore.update((s) => ({
...s,
id,
busy: true,
error: "",
}));
try {
const detail = await getConversationApi(id);
activeConversationStore.set({
id: detail.id,
title: detail.title,
messages: detail.messages,
busy: false,
error: "",
});
} catch (error) {
activeConversationStore.update((s) => ({
...s,
busy: false,
error: error instanceof Error ? error.message : String(error),
}));
}
}
export async function sendConversationMessage(
prompt: string,
): Promise<void> {
const state = get(activeConversationStore);
if (!state.id) {
// Auto-create a conversation from the first message
const title =
prompt.length > 40 ? prompt.slice(0, 37) + "..." : prompt;
const convId = await createNewConversation(title);
if (!convId) return;
}
const currentId = get(activeConversationStore).id;
if (!currentId) return;
// Optimistically add user message
const tempUserMsg: ConversationMessageDto = {
id: `temp-${Date.now()}`,
role: "user",
text: prompt,
createdAt: new Date().toISOString(),
};
activeConversationStore.update((s) => ({
...s,
messages: [...s.messages, tempUserMsg],
busy: true,
error: "",
}));
try {
const result = await conversationChatApi(currentId, prompt);
activeConversationStore.update((s) => ({
...s,
busy: false,
// Replace temp user message + add assistant message
messages: [
...s.messages.filter((m) => m.id !== tempUserMsg.id),
result.userMessage,
result.assistantMessage,
],
}));
// Update conversation list (updated_at changes)
void loadConversations();
} catch (error) {
const errorMsg: ConversationMessageDto = {
id: `error-${Date.now()}`,
role: "error",
text: error instanceof Error ? error.message : String(error),
createdAt: new Date().toISOString(),
};
activeConversationStore.update((s) => ({
...s,
busy: false,
messages: [...s.messages, errorMsg],
}));
}
}
export async function renameConversation(
id: string,
title: string,
): Promise<void> {
try {
await updateConversationApi(id, title);
conversationsStore.update((s) => ({
...s,
items: s.items.map((c) => (c.id === id ? { ...c, title } : c)),
}));
const active = get(activeConversationStore);
if (active.id === id) {
activeConversationStore.update((s) => ({ ...s, title }));
}
} catch (error) {
conversationsStore.update((s) => ({
...s,
error: error instanceof Error ? error.message : String(error),
}));
}
}
export async function removeConversation(id: string): Promise<void> {
try {
await deleteConversationApi(id);
conversationsStore.update((s) => ({
...s,
items: s.items.filter((c) => c.id !== id),
}));
// If this was the active conversation, clear it
const active = get(activeConversationStore);
if (active.id === id) {
clearActiveConversation();
}
} catch (error) {
conversationsStore.update((s) => ({
...s,
error: error instanceof Error ? error.message : String(error),
}));
}
}
export function clearActiveConversation(): void {
activeConversationStore.set({
id: "",
title: "",
messages: [],
busy: false,
error: "",
});
}

View File

@ -10,6 +10,7 @@ const startupViews = [
"fragments",
"todos",
"lists",
"coach",
] as const;
const defaultStartupView = "entries";
export type StartupView = (typeof startupViews)[number];

View File

@ -109,8 +109,8 @@
case "calendar":
case "fragments":
case "todos":
case "lists":
case "entries":
case "coach":
return value;
default:
return "entries";
@ -126,6 +126,7 @@
case "fragments":
case "todos":
case "lists":
case "coach":
return normalized;
default:
return null;

View File

@ -0,0 +1,32 @@
namespace Journal.Core.Dtos;
public sealed record CoachPlanDto(
string Kind,
string Title,
string Summary,
IReadOnlyList<string> Questions,
IReadOnlyList<string> SuggestedNextActions,
IReadOnlyList<string> SuggestedTags,
IReadOnlyList<CoachEvidenceDto> Evidence,
CoachPatchProposalDto? PatchProposal = null);
public sealed record CoachEvidenceDto(
string? RecordId,
string Text);
public sealed record CoachPatchProposalDto(
string Kind,
string? Description = null,
string? Content = null);
public sealed record CoachContextDto(
string DateLocal,
string? WeekStartLocal = null,
string? WeekEndLocal = null,
IReadOnlyList<string>? RecentEntries = null,
IReadOnlyList<string>? RecentFragments = null,
CoachPreferencesDto? Preferences = null);
public sealed record CoachPreferencesDto(
int MaxQuestions = 3,
int MaxNextActions = 3);

View File

@ -20,6 +20,9 @@ internal sealed record AiSummarizeEntryPayload(string Content, string? FileStem
internal sealed record AiSummarizeAllPayload(List<string>? Entries);
internal sealed record AiChatPayload(string Prompt);
internal sealed record AiEmbedPayload(string Content);
internal sealed record CoachDailyPayload(string? DateLocal = null, CoachPreferencesDto? Preferences = null, List<string>? RecentEntries = null, List<string>? RecentFragments = null);
internal sealed record CoachEveningPayload(string? DateLocal = null, CoachPreferencesDto? Preferences = null, List<string>? RecentEntries = null, List<string>? RecentFragments = null);
internal sealed record CoachWeeklyPayload(string? WeekStartLocal = null, string? WeekEndLocal = null, CoachPreferencesDto? Preferences = null, List<string>? RecentEntries = null, List<string>? RecentFragments = null);
internal sealed record SpeechTranscribePayload(
string? AudioBase64 = null,
string? Audio_Base64 = null,
@ -30,6 +33,9 @@ internal sealed record SpeechTranscribePayload(
int? SimulateDelayMs = null,
int? Simulate_Delay_Ms = null);
internal sealed record S2TPollPayload(int? MaxItems = null);
internal sealed record ConversationCreatePayload(string Title);
internal sealed record ConversationUpdatePayload(string? Title);
internal sealed record ConversationChatPayload(string ConversationId, string Prompt);
internal sealed record SearchEntriesPayload(
string? Query = null,
string? Section = null,

View File

@ -0,0 +1,28 @@
using System.ComponentModel.DataAnnotations;
namespace Journal.Core.Dtos;
public sealed record ConversationDto(
Guid Id,
string Title,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
public sealed record ConversationDetailDto(
Guid Id,
string Title,
IReadOnlyList<ConversationMessageDto> Messages,
DateTimeOffset CreatedAt,
DateTimeOffset UpdatedAt);
public sealed record ConversationMessageDto(
Guid Id,
string Role,
string Text,
DateTimeOffset CreatedAt);
public sealed record CreateConversationDto(
[Required] string Title);
public sealed record UpdateConversationDto(
string? Title);

View File

@ -9,6 +9,7 @@ using Journal.Core.Services.Database;
using Journal.Core.Services.Entries;
using Journal.Core.Services.Fragments;
using Journal.Core.Services.Lists;
using Journal.Core.Services.Conversations;
using Journal.Core.Services.Logging;
using Journal.Core.Services.Speech;
using Journal.Core.Services.Todos;
@ -29,6 +30,8 @@ public class Entry(
IEntryFileService entryFiles,
IListService lists,
ITodoService todos,
ICoachService coach,
IConversationService conversations,
CommandLogger logger)
{
private readonly IFragmentService _fragments = fragments;
@ -43,6 +46,8 @@ public class Entry(
private readonly IEntryFileService _entryFiles = entryFiles;
private readonly IListService _lists = lists;
private readonly ITodoService _todos = todos;
private readonly ICoachService _coach = coach;
private readonly IConversationService _conversations = conversations;
private readonly CommandLogger _logger = logger;
private static readonly HashSet<string> VaultSyncActions = new(StringComparer.Ordinal)
{
@ -61,7 +66,11 @@ public class Entry(
"todos.delete",
"todos.items.create",
"todos.items.update",
"todos.items.delete"
"todos.items.delete",
"conversations.create",
"conversations.update",
"conversations.delete",
"conversations.chat"
};
private static readonly JsonSerializerOptions JsonOptions = new()
{
@ -303,6 +312,84 @@ public class Entry(
return Error("Missing or invalid payload");
result = await _ai.EmbedAsync(embedPayload.Content);
break;
// ── Coach ─────────────────────────────────────────
case "ai.coach.daily":
var coachDailyPayload = DeserializePayload<CoachDailyPayload>(cmd.Payload);
result = await _coach.DailyCheckInAsync(new CoachContextDto(
DateLocal: coachDailyPayload?.DateLocal ?? DateTime.Now.ToString("yyyy-MM-dd"),
RecentEntries: coachDailyPayload?.RecentEntries,
RecentFragments: coachDailyPayload?.RecentFragments,
Preferences: coachDailyPayload?.Preferences));
break;
case "ai.coach.evening":
var coachEveningPayload = DeserializePayload<CoachEveningPayload>(cmd.Payload);
result = await _coach.EveningReviewAsync(new CoachContextDto(
DateLocal: coachEveningPayload?.DateLocal ?? DateTime.Now.ToString("yyyy-MM-dd"),
RecentEntries: coachEveningPayload?.RecentEntries,
RecentFragments: coachEveningPayload?.RecentFragments,
Preferences: coachEveningPayload?.Preferences));
break;
case "ai.coach.weekly":
var coachWeeklyPayload = DeserializePayload<CoachWeeklyPayload>(cmd.Payload);
var now = DateTime.Now;
var weekStart = now.AddDays(-(int)now.DayOfWeek + (int)DayOfWeek.Monday);
result = await _coach.WeeklyReviewAsync(new CoachContextDto(
DateLocal: now.ToString("yyyy-MM-dd"),
WeekStartLocal: coachWeeklyPayload?.WeekStartLocal ?? weekStart.ToString("yyyy-MM-dd"),
WeekEndLocal: coachWeeklyPayload?.WeekEndLocal ?? weekStart.AddDays(6).ToString("yyyy-MM-dd"),
RecentEntries: coachWeeklyPayload?.RecentEntries,
RecentFragments: coachWeeklyPayload?.RecentFragments,
Preferences: coachWeeklyPayload?.Preferences));
break;
// ── Conversations ──────────────────────────────────
case "conversations.list":
result = _conversations.GetAll();
break;
case "conversations.get":
if (!Guid.TryParse(cmd.Id, out var getConvId))
return Error("Invalid or missing id");
result = _conversations.GetById(getConvId);
break;
case "conversations.create":
var convCreatePayload = DeserializePayload<ConversationCreatePayload>(cmd.Payload);
if (convCreatePayload is null || string.IsNullOrWhiteSpace(convCreatePayload.Title))
return Error("Missing or invalid payload");
result = _conversations.Create(new CreateConversationDto(convCreatePayload.Title));
break;
case "conversations.update":
if (!Guid.TryParse(cmd.Id, out var updateConvId))
return Error("Invalid or missing id");
var convUpdatePayload = DeserializePayload<ConversationUpdatePayload>(cmd.Payload);
if (convUpdatePayload is null)
return Error("Missing or invalid payload");
result = _conversations.Update(updateConvId, new UpdateConversationDto(convUpdatePayload.Title));
break;
case "conversations.delete":
if (!Guid.TryParse(cmd.Id, out var deleteConvId))
return Error("Invalid or missing id");
result = _conversations.Remove(deleteConvId);
break;
case "conversations.chat":
var convChatPayload = DeserializePayload<ConversationChatPayload>(cmd.Payload);
if (convChatPayload is null || string.IsNullOrWhiteSpace(convChatPayload.Prompt)
|| !Guid.TryParse(convChatPayload.ConversationId, out var chatConvId))
return Error("Missing or invalid payload");
// Save user message
var userMsg = _conversations.AddMessage(chatConvId, "user", convChatPayload.Prompt);
// Build history from existing messages
var history = _conversations.GetMessages(chatConvId)
.Where(m => m.Id != userMsg.Id)
.Select(m => (m.Role, m.Text))
.ToList();
// Get AI response with full conversation context
var aiResponse = await _ai.ChatWithHistoryAsync(history, convChatPayload.Prompt);
// Save AI response
var assistantMsg = _conversations.AddMessage(chatConvId, "assistant", aiResponse);
result = new { userMessage = userMsg, assistantMessage = assistantMsg };
break;
case "speech.devices.list":
result = await _speech.ListDevicesAsync();
break;

View File

@ -0,0 +1,33 @@
namespace Journal.Core.Models;
public class Conversation
{
public Guid Id { get; }
public string Title { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public DateTimeOffset UpdatedAt { get; set; }
public Conversation(string title)
{
if (string.IsNullOrWhiteSpace(title))
throw new ArgumentException("Title is required", nameof(title));
Id = Guid.NewGuid();
Title = title.Trim();
CreatedAt = DateTimeOffset.Now;
UpdatedAt = CreatedAt;
}
public Conversation(Guid id, string title, DateTimeOffset createdAt, DateTimeOffset updatedAt)
{
if (id == Guid.Empty)
throw new ArgumentException("Id is required", nameof(id));
if (string.IsNullOrWhiteSpace(title))
throw new ArgumentException("Title is required", nameof(title));
Id = id;
Title = title.Trim();
CreatedAt = createdAt;
UpdatedAt = updatedAt;
}
}

View File

@ -0,0 +1,38 @@
namespace Journal.Core.Models;
public class ConversationMessage
{
public Guid Id { get; }
public Guid ConversationId { get; }
public string Role { get; set; }
public string Text { get; set; }
public DateTimeOffset CreatedAt { get; set; }
public ConversationMessage(Guid conversationId, string role, string text)
{
if (conversationId == Guid.Empty)
throw new ArgumentException("ConversationId is required", nameof(conversationId));
if (string.IsNullOrWhiteSpace(role))
throw new ArgumentException("Role is required", nameof(role));
if (string.IsNullOrWhiteSpace(text))
throw new ArgumentException("Text is required", nameof(text));
Id = Guid.NewGuid();
ConversationId = conversationId;
Role = role.Trim();
Text = text;
CreatedAt = DateTimeOffset.Now;
}
public ConversationMessage(Guid id, Guid conversationId, string role, string text, DateTimeOffset createdAt)
{
if (id == Guid.Empty)
throw new ArgumentException("Id is required", nameof(id));
Id = id;
ConversationId = conversationId;
Role = role;
Text = text;
CreatedAt = createdAt;
}
}

View File

@ -24,4 +24,5 @@ public sealed record JournalConfig(
string AiProvider,
string PythonExecutable,
string PythonAiSidecarPath,
int AiSidecarTimeoutMs);
int AiSidecarTimeoutMs,
string GgufModelPath);

View File

@ -0,0 +1,14 @@
using Journal.Core.Models;
namespace Journal.Core.Repositories;
public interface IConversationRepository
{
List<Conversation> GetAll();
Conversation? GetById(Guid id);
void Add(Conversation conversation);
bool Update(Guid id, string? title = null);
bool Remove(Guid id);
void AddMessage(ConversationMessage message);
List<ConversationMessage> GetMessages(Guid conversationId);
}

View File

@ -0,0 +1,217 @@
using Journal.Core.Models;
using Journal.Core.Services.Database;
using Microsoft.Data.Sqlite;
namespace Journal.Core.Repositories;
public sealed class SqliteConversationRepository(IDatabaseSessionService session) : IConversationRepository
{
private readonly IDatabaseSessionService _session = session;
public List<Conversation> GetAll()
{
var conn = _session.GetConnection();
return ReadAll(conn);
}
public Conversation? GetById(Guid id)
{
var conn = _session.GetConnection();
return ReadById(conn, id);
}
public void Add(Conversation conversation)
{
ArgumentNullException.ThrowIfNull(conversation);
var conn = _session.GetConnection();
Insert(conn, conversation);
}
public bool Update(Guid id, string? title = null)
{
var conn = _session.GetConnection();
var existing = ReadById(conn, id);
if (existing is null)
return false;
if (title is not null)
{
if (string.IsNullOrWhiteSpace(title))
throw new ArgumentException("Title cannot be empty", nameof(title));
existing.Title = title.Trim();
}
existing.UpdatedAt = DateTimeOffset.Now;
UpdateRow(conn, existing);
return true;
}
public bool Remove(Guid id)
{
var conn = _session.GetConnection();
return Delete(conn, id);
}
public void AddMessage(ConversationMessage message)
{
ArgumentNullException.ThrowIfNull(message);
var conn = _session.GetConnection();
InsertMessage(conn, message);
// Touch conversation updated_at
using var cmd = conn.CreateCommand();
cmd.CommandText = "UPDATE conversations SET updated_at = @now WHERE guid = @guid;";
cmd.Parameters.AddWithValue("@now", DateTimeOffset.Now.ToString("O"));
cmd.Parameters.AddWithValue("@guid", message.ConversationId.ToString("D"));
cmd.ExecuteNonQuery();
}
public List<ConversationMessage> GetMessages(Guid conversationId)
{
var conn = _session.GetConnection();
return ReadMessages(conn, conversationId);
}
// ── Private helpers ──────────────────────────────────────────────
private static void Insert(SqliteConnection conn, Conversation c)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO conversations (guid, title, created_at, updated_at)
VALUES (@guid, @title, @createdAt, @updatedAt);
""";
cmd.Parameters.AddWithValue("@guid", c.Id.ToString("D"));
cmd.Parameters.AddWithValue("@title", c.Title);
cmd.Parameters.AddWithValue("@createdAt", c.CreatedAt.ToString("O"));
cmd.Parameters.AddWithValue("@updatedAt", c.UpdatedAt.ToString("O"));
cmd.ExecuteNonQuery();
}
private static void UpdateRow(SqliteConnection conn, Conversation c)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = """
UPDATE conversations SET title = @title, updated_at = @updatedAt
WHERE guid = @guid;
""";
cmd.Parameters.AddWithValue("@guid", c.Id.ToString("D"));
cmd.Parameters.AddWithValue("@title", c.Title);
cmd.Parameters.AddWithValue("@updatedAt", c.UpdatedAt.ToString("O"));
cmd.ExecuteNonQuery();
}
private static bool Delete(SqliteConnection conn, Guid id)
{
using var tx = conn.BeginTransaction();
// Get the row id for cascade delete of messages
var rowId = GetRowId(conn, id);
if (rowId.HasValue)
{
using var delMsgs = conn.CreateCommand();
delMsgs.CommandText = "DELETE FROM conversation_messages WHERE conversation_id = @id;";
delMsgs.Parameters.AddWithValue("@id", rowId.Value);
delMsgs.ExecuteNonQuery();
}
using var cmd = conn.CreateCommand();
cmd.CommandText = "DELETE FROM conversations WHERE guid = @guid;";
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
var rows = cmd.ExecuteNonQuery();
tx.Commit();
return rows > 0;
}
private static long? GetRowId(SqliteConnection conn, Guid id)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT id FROM conversations WHERE guid = @guid;";
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
var result = cmd.ExecuteScalar();
return result is long rowId ? rowId : null;
}
private static Conversation? ReadById(SqliteConnection conn, Guid id)
{
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT guid, title, created_at, updated_at FROM conversations WHERE guid = @guid;";
cmd.Parameters.AddWithValue("@guid", id.ToString("D"));
using var reader = cmd.ExecuteReader();
return reader.Read() ? MapConversation(reader) : null;
}
private static List<Conversation> ReadAll(SqliteConnection conn)
{
var results = new List<Conversation>();
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT guid, title, created_at, updated_at FROM conversations ORDER BY updated_at DESC;";
using var reader = cmd.ExecuteReader();
while (reader.Read())
results.Add(MapConversation(reader));
return results;
}
private static void InsertMessage(SqliteConnection conn, ConversationMessage m)
{
var convRowId = GetRowId(conn, m.ConversationId)
?? throw new InvalidOperationException($"Conversation {m.ConversationId} not found");
using var cmd = conn.CreateCommand();
cmd.CommandText = """
INSERT INTO conversation_messages (guid, conversation_id, role, text, created_at)
VALUES (@guid, @conversationId, @role, @text, @createdAt);
""";
cmd.Parameters.AddWithValue("@guid", m.Id.ToString("D"));
cmd.Parameters.AddWithValue("@conversationId", convRowId);
cmd.Parameters.AddWithValue("@role", m.Role);
cmd.Parameters.AddWithValue("@text", m.Text);
cmd.Parameters.AddWithValue("@createdAt", m.CreatedAt.ToString("O"));
cmd.ExecuteNonQuery();
}
private static List<ConversationMessage> ReadMessages(SqliteConnection conn, Guid conversationId)
{
var rowId = GetRowId(conn, conversationId);
if (!rowId.HasValue)
return [];
var results = new List<ConversationMessage>();
using var cmd = conn.CreateCommand();
cmd.CommandText = """
SELECT guid, role, text, created_at
FROM conversation_messages
WHERE conversation_id = @conversationId
ORDER BY created_at ASC;
""";
cmd.Parameters.AddWithValue("@conversationId", rowId.Value);
using var reader = cmd.ExecuteReader();
while (reader.Read())
results.Add(MapMessage(reader, conversationId));
return results;
}
private static Conversation MapConversation(SqliteDataReader reader)
{
var guid = Guid.Parse(reader.GetString(0));
var title = reader.GetString(1);
var createdAt = reader.IsDBNull(2) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(reader.GetString(2));
var updatedAt = reader.IsDBNull(3) ? createdAt : DateTimeOffset.Parse(reader.GetString(3));
return new Conversation(guid, title, createdAt, updatedAt);
}
private static ConversationMessage MapMessage(SqliteDataReader reader, Guid conversationId)
{
var guid = Guid.Parse(reader.GetString(0));
var role = reader.GetString(1);
var text = reader.IsDBNull(2) ? "" : reader.GetString(2);
var createdAt = reader.IsDBNull(3) ? DateTimeOffset.MinValue : DateTimeOffset.Parse(reader.GetString(3));
return new ConversationMessage(guid, conversationId, role, text, createdAt);
}
}

View File

@ -9,6 +9,7 @@ using Journal.Core.Services.Lists;
using Journal.Core.Services.Logging;
using Journal.Core.Services.Sidecar;
using Journal.Core.Services.Speech;
using Journal.Core.Services.Conversations;
using Journal.Core.Services.Todos;
using Journal.Core.Services.Vault;
@ -65,6 +66,9 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IListService, ListService>();
services.AddSingleton<ITodoRepository, SqliteTodoRepository>();
services.AddSingleton<ITodoService, TodoService>();
services.AddSingleton<ICoachService>(new DisabledCoachService());
services.AddSingleton<IConversationRepository, SqliteConversationRepository>();
services.AddSingleton<IConversationService, ConversationService>();
services.AddSingleton<CommandLogger>();
services.AddSingleton<SidecarCli>();
return services;

View File

@ -20,6 +20,9 @@ public sealed class DisabledAiService(string provider, string message = "AI prov
public Task<string> ChatAsync(string prompt, CancellationToken cancellationToken = default) =>
Task.FromResult(_message);
public Task<string> ChatWithHistoryAsync(IReadOnlyList<(string Role, string Text)> history, string prompt, CancellationToken cancellationToken = default) =>
Task.FromResult(_message);
public Task<IReadOnlyList<double>> EmbedAsync(string content, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<double>>([]);
}

View File

@ -0,0 +1,26 @@
using Journal.Core.Dtos;
namespace Journal.Core.Services.Ai;
public sealed class DisabledCoachService(string message = "Coach is not available. Set JOURNAL_AI_PROVIDER to enable.") : ICoachService
{
private readonly string _message = message;
public Task<CoachPlanDto> DailyCheckInAsync(CoachContextDto context, CancellationToken cancellationToken = default)
=> Task.FromResult(Disabled("daily_checkin"));
public Task<CoachPlanDto> EveningReviewAsync(CoachContextDto context, CancellationToken cancellationToken = default)
=> Task.FromResult(Disabled("evening_review"));
public Task<CoachPlanDto> WeeklyReviewAsync(CoachContextDto context, CancellationToken cancellationToken = default)
=> Task.FromResult(Disabled("weekly_review"));
private CoachPlanDto Disabled(string kind) => new(
Kind: kind,
Title: "Coach Disabled",
Summary: _message,
Questions: [],
SuggestedNextActions: [],
SuggestedTags: [],
Evidence: []);
}

View File

@ -8,5 +8,6 @@ public interface IAiService
Task<string> SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default);
Task<string> SummarizeAllAsync(IReadOnlyList<string> entries, CancellationToken cancellationToken = default);
Task<string> ChatAsync(string prompt, CancellationToken cancellationToken = default);
Task<string> ChatWithHistoryAsync(IReadOnlyList<(string Role, string Text)> history, string prompt, CancellationToken cancellationToken = default);
Task<IReadOnlyList<double>> EmbedAsync(string content, CancellationToken cancellationToken = default);
}

View File

@ -0,0 +1,10 @@
using Journal.Core.Dtos;
namespace Journal.Core.Services.Ai;
public interface ICoachService
{
Task<CoachPlanDto> DailyCheckInAsync(CoachContextDto context, CancellationToken cancellationToken = default);
Task<CoachPlanDto> EveningReviewAsync(CoachContextDto context, CancellationToken cancellationToken = default);
Task<CoachPlanDto> WeeklyReviewAsync(CoachContextDto context, CancellationToken cancellationToken = default);
}

View File

@ -61,6 +61,13 @@ public sealed class PythonSidecarAiService : IAiService
return data?.GetString() ?? "";
}
public Task<string> ChatWithHistoryAsync(IReadOnlyList<(string Role, string Text)> history,
string prompt, CancellationToken cancellationToken = default)
{
// Python sidecar does not support multi-turn — fall back to single-turn
return ChatAsync(prompt, cancellationToken);
}
public async Task<IReadOnlyList<double>> EmbedAsync(string content, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(content))

View File

@ -21,8 +21,8 @@ public sealed class JournalConfigService : IJournalConfigService
if (nlpBackend is not ("auto" or "spacy" or "fallback"))
nlpBackend = "auto";
var aiProvider = (Environment.GetEnvironmentVariable("JOURNAL_AI_PROVIDER") ?? "none").Trim().ToLowerInvariant();
if (aiProvider is not ("none" or "python-sidecar"))
var aiProvider = (Environment.GetEnvironmentVariable("JOURNAL_AI_PROVIDER") ?? "llamasharp").Trim().ToLowerInvariant();
if (aiProvider is not ("none" or "python-sidecar" or "llamasharp"))
aiProvider = "none";
var pythonExecutable = Environment.GetEnvironmentVariable("JOURNAL_PYTHON_EXE");
@ -57,7 +57,8 @@ public sealed class JournalConfigService : IJournalConfigService
AiProvider: aiProvider,
PythonExecutable: pythonExecutable,
PythonAiSidecarPath: pythonAiSidecarPath,
AiSidecarTimeoutMs: aiSidecarTimeoutMs);
AiSidecarTimeoutMs: aiSidecarTimeoutMs,
GgufModelPath: ResolveGgufModelPath(projectRoot));
}
private static string ResolveProjectRoot()
@ -101,5 +102,22 @@ public sealed class JournalConfigService : IJournalConfigService
return null;
return int.TryParse(value, out var parsed) ? parsed : null;
}
private static string ResolveGgufModelPath(string projectRoot)
{
var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_GGUF_MODEL_PATH");
if (!string.IsNullOrWhiteSpace(fromEnv))
return Path.GetFullPath(fromEnv);
var modelsDir = Path.Combine(projectRoot, "models");
if (Directory.Exists(modelsDir))
{
var first = Directory.EnumerateFiles(modelsDir, "*.gguf").FirstOrDefault();
if (first is not null)
return Path.GetFullPath(first);
}
return Path.Combine(modelsDir, "model.gguf");
}
}

View File

@ -0,0 +1,68 @@
using System.ComponentModel.DataAnnotations;
using Journal.Core.Dtos;
using Journal.Core.Models;
using Journal.Core.Repositories;
namespace Journal.Core.Services.Conversations;
public class ConversationService(IConversationRepository repo) : IConversationService
{
private readonly IConversationRepository _repo = repo ?? throw new ArgumentNullException(nameof(repo));
private static ConversationDto MapSummary(Conversation c) => new(c.Id, c.Title, c.CreatedAt, c.UpdatedAt);
private static ConversationMessageDto MapMessage(ConversationMessage m) => new(m.Id, m.Role, m.Text, m.CreatedAt);
public List<ConversationDto> GetAll()
{
var items = _repo.GetAll();
return [.. items.Select(MapSummary)];
}
public ConversationDetailDto? GetById(Guid id)
{
var c = _repo.GetById(id);
if (c is null) return null;
var messages = _repo.GetMessages(id);
return new ConversationDetailDto(
c.Id, c.Title,
[.. messages.Select(MapMessage)],
c.CreatedAt, c.UpdatedAt);
}
public ConversationDto Create(CreateConversationDto dto)
{
ArgumentNullException.ThrowIfNull(dto);
var ctx = new ValidationContext(dto);
Validator.ValidateObject(dto, ctx, validateAllProperties: true);
var conversation = new Conversation(dto.Title);
_repo.Add(conversation);
return MapSummary(conversation);
}
public bool Update(Guid id, UpdateConversationDto dto)
{
ArgumentNullException.ThrowIfNull(dto);
if (dto.Title is not null && string.IsNullOrWhiteSpace(dto.Title))
throw new ValidationException("Title cannot be empty");
return _repo.Update(id, dto.Title?.Trim());
}
public bool Remove(Guid id) => _repo.Remove(id);
public ConversationMessageDto AddMessage(Guid conversationId, string role, string text)
{
var message = new ConversationMessage(conversationId, role, text);
_repo.AddMessage(message);
return MapMessage(message);
}
public List<ConversationMessageDto> GetMessages(Guid conversationId)
{
var messages = _repo.GetMessages(conversationId);
return [.. messages.Select(MapMessage)];
}
}

View File

@ -0,0 +1,14 @@
using Journal.Core.Dtos;
namespace Journal.Core.Services.Conversations;
public interface IConversationService
{
List<ConversationDto> GetAll();
ConversationDetailDto? GetById(Guid id);
ConversationDto Create(CreateConversationDto dto);
bool Update(Guid id, UpdateConversationDto dto);
bool Remove(Guid id);
ConversationMessageDto AddMessage(Guid conversationId, string role, string text);
List<ConversationMessageDto> GetMessages(Guid conversationId);
}

View File

@ -15,7 +15,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
private static readonly Lock SqliteInitLock = new();
private static bool _sqliteInitialized;
private static readonly IReadOnlyList<string> RequiredSchemaTables =
["entries", "sections", "fragments", "tags", "fragment_tags", "lists", "todo_lists", "todo_items", "entry_documents"];
["entries", "sections", "fragments", "tags", "fragment_tags", "lists", "todo_lists", "todo_items", "entry_documents", "conversations", "conversation_messages"];
private readonly IJournalConfigService _config = config;
@ -129,6 +129,26 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
is_template INTEGER NOT NULL DEFAULT 0,
updated_at TEXT
);
""",
["conversations"] = """
CREATE TABLE IF NOT EXISTS conversations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE,
title TEXT NOT NULL,
created_at TEXT,
updated_at TEXT
);
""",
["conversation_messages"] = """
CREATE TABLE IF NOT EXISTS conversation_messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
guid TEXT UNIQUE,
conversation_id INTEGER NOT NULL,
role TEXT NOT NULL,
text TEXT NOT NULL,
created_at TEXT,
FOREIGN KEY (conversation_id) REFERENCES conversations (id)
);
"""
};
}

View File

@ -1,4 +1,5 @@
using Microsoft.Extensions.DependencyInjection;
using Journal.AI;
using Journal.Core;
using Journal.Core.Services.Speech;
using Journal.Core.Services.Sidecar;
@ -9,6 +10,7 @@ Console.InputEncoding = System.Text.Encoding.UTF8;
var services = new ServiceCollection();
services.AddFragmentServices();
services.AddLlamaSharpServices();
services.AddSingleton<IS2TService, LocalWhisperS2TService>();
services.AddSingleton<Entry>();
var provider = services.BuildServiceProvider();

View File

@ -10,6 +10,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Journal.AI\Journal.AI.csproj" />
<ProjectReference Include="..\Journal.Core\Journal.Core.csproj" />
</ItemGroup>

View File

@ -16,8 +16,8 @@ public sealed class LocalWhisperS2TService : IS2TService, IDisposable
private const int MaxBufferedItems = 256;
private const int SilenceRmsThreshold = 150;
private readonly object _sync = new();
private readonly object _segmentLock = new();
private readonly Lock _sync = new();
private readonly Lock _segmentLock = new();
private readonly ConcurrentQueue<string> _transcripts = new();
private WaveInEvent? _waveIn;

View File

@ -15,4 +15,5 @@ global using Journal.Core.Services.Speech;
global using Journal.Core.Services.Sidecar;
global using Journal.Core.Services.Lists;
global using Journal.Core.Services.Todos;
global using Journal.Core.Services.Conversations;
global using Journal.Core.Services.Vault;

View File

@ -25,9 +25,12 @@ internal static partial class Program
config,
new DisabledAiService("none"),
new DisabledSpeechBridgeService("none"),
new DisabledS2TService(),
new EntryFileService(entryRepo),
new ListService(new SqliteListRepository(session)),
new TodoService(new SqliteTodoRepository(session)),
new DisabledCoachService(),
new ConversationService(new SqliteConversationRepository(session)),
new CommandLogger());
}

View File

@ -437,9 +437,12 @@ internal static partial class Program
config,
new DisabledAiService("none"),
new DisabledSpeechBridgeService("none"),
new DisabledS2TService(),
entryFiles,
new ListService(new SqliteListRepository(session)),
new TodoService(new SqliteTodoRepository(session)),
new DisabledCoachService(),
new ConversationService(new SqliteConversationRepository(session)),
new CommandLogger());
try

View File

@ -7,6 +7,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\Journal.AI\Journal.AI.csproj" />
<ProjectReference Include="..\Journal.Core\Journal.Core.csproj" />
</ItemGroup>

View File

@ -1,5 +1,6 @@
using System.Text.Json;
using System.Text.Json.Serialization;
using Journal.AI;
using Journal.Core;
using Microsoft.Extensions.FileProviders;
@ -17,6 +18,7 @@ var webDistPath = ResolveWebDist(repoRoot);
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddFragmentServices();
builder.Services.AddLlamaSharpServices();
builder.Services.AddSingleton<Entry>();
builder.Services.AddSingleton(new SidecarRootState(repoRoot));
builder.Services.AddSingleton(new WebUiState(webDistPath));

View File

@ -1,4 +1,5 @@
<Solution>
<Project Path="Journal.AI/Journal.AI.csproj" />
<Project Path="Journal.Core/Journal.Core.csproj" />
<Project Path="Journal.Sidecar/Journal.Sidecar.csproj" />
<Project Path="Journal.SmokeTests/Journal.SmokeTests.csproj" />

View File

@ -1,218 +0,0 @@
# Wiring Frontend to the C# Backend
This document explains how to connect the `Journal.App` frontend to the C# backend in this repository.
## Current Backend Reality
In this repo today, the C# backend projects in `Journal.slnx` are:
- `Journal.Core`
- `Journal.Sidecar`
- `Journal.SmokeTests`
There is currently **no** `Journal.Api` project in the solution file, so the primary integration path is:
- Frontend (Svelte/Tauri) -> Tauri bridge -> `Journal.Sidecar` (stdin/stdout JSON protocol)
## Command Protocol (C#)
`Journal.Core.Entry.HandleCommandAsync` accepts a JSON command envelope and returns:
- success: `{ "ok": true, "data": ... }`
- failure: `{ "ok": false, "error": "..." }`
Command model (`Journal.Core/Models/Command.cs`):
```json
{
"action": "entries.list",
"correlationId": "optional-string",
"id": "optional",
"type": "optional",
"tag": "optional",
"payload": {}
}
```
Useful actions for frontend wiring:
- `entries.list`
- `entries.load`
- `entries.save`
- `search.entries`
- `vault.load_all`
- `vault.save_current_month`
- `db.status`
- `db.hydrate_workspace`
## Recommended Integration (Sidecar Bridge)
Use a small frontend client that sends commands through one bridge function. The bridge can be backed by:
- a Tauri command that talks to a managed sidecar process, or
- a local HTTP adapter (if you add one).
### 1. Define shared frontend command/response types
Create `Journal.App/src/lib/backend/types.ts`:
```ts
export type BackendCommand = {
action: string;
correlationId?: string;
id?: string;
type?: string;
tag?: string;
payload?: unknown;
};
export type BackendOk<T> = { ok: true; data: T };
export type BackendErr = { ok: false; error: string };
export type BackendResponse<T> = BackendOk<T> | BackendErr;
```
### 2. Create one backend client entrypoint
Create `Journal.App/src/lib/backend/client.ts`:
```ts
import { invoke } from "@tauri-apps/api/core";
import type { BackendCommand, BackendResponse } from "./types";
export async function sendCommand<T>(command: BackendCommand): Promise<T> {
const response = await invoke<BackendResponse<T>>("sidecar_command", { command });
if (!response.ok) {
throw new Error(response.error || "Backend command failed");
}
return response.data;
}
```
This keeps all UI code backend-agnostic.
### 3. Build domain helpers (entries example)
Create `Journal.App/src/lib/backend/entries.ts`:
```ts
import { sendCommand } from "./client";
export async function listEntries(dataDirectory?: string) {
return sendCommand<string[]>({
action: "entries.list",
payload: { dataDirectory }
});
}
export async function loadEntry(filePath: string) {
return sendCommand<{ filePath: string; content: string; section?: string }>({
action: "entries.load",
payload: { filePath }
});
}
export async function saveEntry(args: {
filePath?: string;
content: string;
title?: string;
section?: string;
date?: string;
}) {
return sendCommand<{ filePath: string }>({
action: "entries.save",
payload: args
});
}
```
### 4. Use client in UI state
In page/component code:
- on panel item click: call `loadEntry(filePath)`
- on editor save button: call `saveEntry({ filePath, content })`
- on app init: call `listEntries()` to populate list
## Tauri Bridge Notes
Your frontend should not spawn/process-manage the sidecar directly. Keep that in the Tauri layer.
Bridge responsibilities:
- start and keep one sidecar process alive
- write command JSON lines to sidecar stdin
- read stdout lines and map responses by `correlationId`
- return parsed response to frontend
- restart sidecar if it crashes
If you have not implemented this yet, create one Tauri command such as:
- `sidecar_command(command)`
and route all frontend calls through it.
## Vault/Auth Flow
Recommended startup sequence:
1. Prompt for vault password in UI.
2. Call `vault.load_all` (or `db.hydrate_workspace`) once.
3. Backend stores session password (`DatabaseSessionService`) for subsequent commands.
4. Continue with `entries.list`, `entries.load`, etc.
Do not store raw vault password in long-lived frontend state.
## Error Handling Pattern
Always normalize backend errors in one place:
- backend client throws `Error(message)` when `ok: false`
- UI catches and displays your custom modal
- include `correlationId` on commands for tracing/logging
## Optional HTTP Path (If You Add Journal.Api)
If you later add `Journal.Api` with `POST /api/command`, keep the same command envelope and swap transport only:
- replace `invoke("sidecar_command", ...)` with `fetch("/api/command", ...)`
- keep `sendCommand` interface unchanged
That lets UI code remain identical.
## Minimal Next Steps
1. Add `src/lib/backend/types.ts`, `client.ts`, `entries.ts`.
2. Wire `EditorPanel` save button to `entries.save`.
3. Wire `SidePanel` item load to `entries.load`.
4. Add vault unlock modal + `vault.load_all` on startup.
5. Keep all backend calls behind `sendCommand` only.
## Frontend Store Architecture (Current)
Current frontend uses feature stores in `Journal.App/src/lib/stores/`:
- `entries.ts` -> `entriesStore`
- `fragments.ts` -> `fragmentsStore`
- `todos.ts` -> `todoListsStore`, `todosStore`
- `lists.ts` -> `listsStore`
- `settings.ts` -> `settingsTags`, `settingsFragmentTypes`
Current pattern is store-first for most feature CRUD and parsing (especially fragments and todos), with UI components invoking store helpers.
## State/CRUD Gaps Still Needed
To fully standardize state management:
1. Move settings add/edit/remove logic into `settings.ts` helper functions (currently in route component code).
2. Add full CRUD helpers for `entries.ts` and `lists.ts` (update/remove/reorder, not only draft creation).
3. Make todo list metadata + todo items update atomically through a single store API wrapper.
4. Move calendar-created entries out of local component state into a dedicated calendar store.
5. Add persistence/hydration strategy between stores and backend (`entries.load/save`, `vault.load_all`, etc.).
## Recommended Rule
- Keep all feature data mutations in store helper APIs.
- Keep route/component files focused on view state and command orchestration.
- Keep backend transport (`sendCommand`) separate from pure local store mutation helpers, then compose both in thin feature services.