Compare commits

...

3 Commits

Author SHA1 Message Date
Jacob Schmidt
53204ec59e Merge branch 'master' of https://gitea.innovativedevsolutions.org/J.Schmidt92/journal 2026-03-01 16:15:16 -06:00
Jacob Schmidt
2aa9850782 style: apply dotnet format across solution
Co-Authored-By: Oz <oz-agent@warp.dev>
2026-03-01 16:15:04 -06:00
Jacob Schmidt
192e6e3891 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>
2026-03-01 16:07:59 -06:00
47 changed files with 3264 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,195 @@
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,706 @@
<!-- @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 ListEditor from "$lib/components/editor/ListEditor.svelte";
import TodoEditor from "$lib/components/editor/TodoEditor.svelte"; import TodoEditor from "$lib/components/editor/TodoEditor.svelte";
import MarkdownEditor from "$lib/components/editor/MarkdownEditor.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 activeSection = "entries";
export let openDocumentId = "entries/daily-notes"; export let openDocumentId = "entries/daily-notes";
@ -154,6 +156,15 @@
</ul> </ul>
{/if} {/if}
</section> </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} {:else if !openDocumentId}
<div class="editor-empty"> <div class="editor-empty">
<span class="material-symbols-outlined empty-icon">edit_note</span> <span class="material-symbols-outlined empty-icon">edit_note</span>

View File

@ -15,6 +15,7 @@
{ id: "fragments", label: "Fragments", icon: "auto_stories" }, { id: "fragments", label: "Fragments", icon: "auto_stories" },
{ id: "todos", label: "To-Do List", icon: "checklist" }, { id: "todos", label: "To-Do List", icon: "checklist" },
{ id: "lists", label: "Lists", icon: "lists" }, { id: "lists", label: "Lists", icon: "lists" },
{ id: "coach", label: "Coach", icon: "psychology" },
]; ];
function selectItem(id: string) { function selectItem(id: string) {

View File

@ -32,6 +32,24 @@
} from "$lib/stores/todos"; } from "$lib/stores/todos";
import { vaultUnlocked } from "$lib/stores/session"; import { vaultUnlocked } from "$lib/stores/session";
import { extractEntryTags } from "$lib/utils/metadata"; 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 activeSection = "entries";
export let activeDocumentId = ""; export let activeDocumentId = "";
@ -94,8 +112,89 @@
fragments: "Fragments", fragments: "Fragments",
todos: "To-Do List", todos: "To-Do List",
lists: "Lists", 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(); const today = new Date();
let calendarYear = today.getFullYear(); let calendarYear = today.getFullYear();
let calendarMonth = today.getMonth(); let calendarMonth = today.getMonth();
@ -938,15 +1037,17 @@
<span class="material-symbols-outlined">calendar_month</span> <span class="material-symbols-outlined">calendar_month</span>
</button> </button>
{/if} {/if}
<button {#if activeSection !== "coach"}
type="button" <button
class="panel-action" type="button"
aria-label="Add item" class="panel-action"
title="Add item" aria-label="Add item"
on:click={handleAddItem} title="Add item"
> on:click={handleAddItem}
<span class="material-symbols-outlined">add</span> >
</button> <span class="material-symbols-outlined">add</span>
</button>
{/if}
</div> </div>
</header> </header>
@ -1085,6 +1186,145 @@
Last refreshed: {calendarLastRefreshedAt || "Not yet"} Last refreshed: {calendarLastRefreshedAt || "Not yet"}
</p> </p>
</div> </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} {:else}
<div class="panel-search"> <div class="panel-search">
<span class="material-symbols-outlined">search</span> <span class="material-symbols-outlined">search</span>
@ -1597,4 +1837,165 @@
grid-template-columns: 1fr; 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> </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,219 @@
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", "fragments",
"todos", "todos",
"lists", "lists",
"coach",
] as const; ] as const;
const defaultStartupView = "entries"; const defaultStartupView = "entries";
export type StartupView = (typeof startupViews)[number]; export type StartupView = (typeof startupViews)[number];

View File

@ -109,8 +109,8 @@
case "calendar": case "calendar":
case "fragments": case "fragments":
case "todos": case "todos":
case "lists":
case "entries": case "entries":
case "coach":
return value; return value;
default: default:
return "entries"; return "entries";
@ -126,6 +126,7 @@
case "fragments": case "fragments":
case "todos": case "todos":
case "lists": case "lists":
case "coach":
return normalized; return normalized;
default: default:
return null; 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 AiSummarizeAllPayload(List<string>? Entries);
internal sealed record AiChatPayload(string Prompt); internal sealed record AiChatPayload(string Prompt);
internal sealed record AiEmbedPayload(string Content); 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( internal sealed record SpeechTranscribePayload(
string? AudioBase64 = null, string? AudioBase64 = null,
string? Audio_Base64 = null, string? Audio_Base64 = null,
@ -30,6 +33,9 @@ internal sealed record SpeechTranscribePayload(
int? SimulateDelayMs = null, int? SimulateDelayMs = null,
int? Simulate_Delay_Ms = null); int? Simulate_Delay_Ms = null);
internal sealed record S2TPollPayload(int? MaxItems = 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( internal sealed record SearchEntriesPayload(
string? Query = null, string? Query = null,
string? Section = 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.Entries;
using Journal.Core.Services.Fragments; using Journal.Core.Services.Fragments;
using Journal.Core.Services.Lists; using Journal.Core.Services.Lists;
using Journal.Core.Services.Conversations;
using Journal.Core.Services.Logging; using Journal.Core.Services.Logging;
using Journal.Core.Services.Speech; using Journal.Core.Services.Speech;
using Journal.Core.Services.Todos; using Journal.Core.Services.Todos;
@ -29,6 +30,8 @@ public class Entry(
IEntryFileService entryFiles, IEntryFileService entryFiles,
IListService lists, IListService lists,
ITodoService todos, ITodoService todos,
ICoachService coach,
IConversationService conversations,
CommandLogger logger) CommandLogger logger)
{ {
private readonly IFragmentService _fragments = fragments; private readonly IFragmentService _fragments = fragments;
@ -43,6 +46,8 @@ public class Entry(
private readonly IEntryFileService _entryFiles = entryFiles; private readonly IEntryFileService _entryFiles = entryFiles;
private readonly IListService _lists = lists; private readonly IListService _lists = lists;
private readonly ITodoService _todos = todos; private readonly ITodoService _todos = todos;
private readonly ICoachService _coach = coach;
private readonly IConversationService _conversations = conversations;
private readonly CommandLogger _logger = logger; private readonly CommandLogger _logger = logger;
private static readonly HashSet<string> VaultSyncActions = new(StringComparer.Ordinal) private static readonly HashSet<string> VaultSyncActions = new(StringComparer.Ordinal)
{ {
@ -61,7 +66,11 @@ public class Entry(
"todos.delete", "todos.delete",
"todos.items.create", "todos.items.create",
"todos.items.update", "todos.items.update",
"todos.items.delete" "todos.items.delete",
"conversations.create",
"conversations.update",
"conversations.delete",
"conversations.chat"
}; };
private static readonly JsonSerializerOptions JsonOptions = new() private static readonly JsonSerializerOptions JsonOptions = new()
{ {
@ -303,6 +312,84 @@ public class Entry(
return Error("Missing or invalid payload"); return Error("Missing or invalid payload");
result = await _ai.EmbedAsync(embedPayload.Content); result = await _ai.EmbedAsync(embedPayload.Content);
break; 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": case "speech.devices.list":
result = await _speech.ListDevicesAsync(); result = await _speech.ListDevicesAsync();
break; 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 AiProvider,
string PythonExecutable, string PythonExecutable,
string PythonAiSidecarPath, 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.Logging;
using Journal.Core.Services.Sidecar; using Journal.Core.Services.Sidecar;
using Journal.Core.Services.Speech; using Journal.Core.Services.Speech;
using Journal.Core.Services.Conversations;
using Journal.Core.Services.Todos; using Journal.Core.Services.Todos;
using Journal.Core.Services.Vault; using Journal.Core.Services.Vault;
@ -65,6 +66,9 @@ public static class ServiceCollectionExtensions
services.AddSingleton<IListService, ListService>(); services.AddSingleton<IListService, ListService>();
services.AddSingleton<ITodoRepository, SqliteTodoRepository>(); services.AddSingleton<ITodoRepository, SqliteTodoRepository>();
services.AddSingleton<ITodoService, TodoService>(); services.AddSingleton<ITodoService, TodoService>();
services.AddSingleton<ICoachService>(new DisabledCoachService());
services.AddSingleton<IConversationRepository, SqliteConversationRepository>();
services.AddSingleton<IConversationService, ConversationService>();
services.AddSingleton<CommandLogger>(); services.AddSingleton<CommandLogger>();
services.AddSingleton<SidecarCli>(); services.AddSingleton<SidecarCli>();
return services; 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) => public Task<string> ChatAsync(string prompt, CancellationToken cancellationToken = default) =>
Task.FromResult(_message); 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) => public Task<IReadOnlyList<double>> EmbedAsync(string content, CancellationToken cancellationToken = default) =>
Task.FromResult<IReadOnlyList<double>>([]); 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> SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default);
Task<string> SummarizeAllAsync(IReadOnlyList<string> entries, CancellationToken cancellationToken = default); Task<string> SummarizeAllAsync(IReadOnlyList<string> entries, CancellationToken cancellationToken = default);
Task<string> ChatAsync(string prompt, 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); 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() ?? ""; 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) public async Task<IReadOnlyList<double>> EmbedAsync(string content, CancellationToken cancellationToken = default)
{ {
if (string.IsNullOrWhiteSpace(content)) if (string.IsNullOrWhiteSpace(content))

View File

@ -21,8 +21,8 @@ public sealed class JournalConfigService : IJournalConfigService
if (nlpBackend is not ("auto" or "spacy" or "fallback")) if (nlpBackend is not ("auto" or "spacy" or "fallback"))
nlpBackend = "auto"; nlpBackend = "auto";
var aiProvider = (Environment.GetEnvironmentVariable("JOURNAL_AI_PROVIDER") ?? "none").Trim().ToLowerInvariant(); var aiProvider = (Environment.GetEnvironmentVariable("JOURNAL_AI_PROVIDER") ?? "llamasharp").Trim().ToLowerInvariant();
if (aiProvider is not ("none" or "python-sidecar")) if (aiProvider is not ("none" or "python-sidecar" or "llamasharp"))
aiProvider = "none"; aiProvider = "none";
var pythonExecutable = Environment.GetEnvironmentVariable("JOURNAL_PYTHON_EXE"); var pythonExecutable = Environment.GetEnvironmentVariable("JOURNAL_PYTHON_EXE");
@ -57,7 +57,8 @@ public sealed class JournalConfigService : IJournalConfigService
AiProvider: aiProvider, AiProvider: aiProvider,
PythonExecutable: pythonExecutable, PythonExecutable: pythonExecutable,
PythonAiSidecarPath: pythonAiSidecarPath, PythonAiSidecarPath: pythonAiSidecarPath,
AiSidecarTimeoutMs: aiSidecarTimeoutMs); AiSidecarTimeoutMs: aiSidecarTimeoutMs,
GgufModelPath: ResolveGgufModelPath(projectRoot));
} }
private static string ResolveProjectRoot() private static string ResolveProjectRoot()
@ -101,5 +102,22 @@ public sealed class JournalConfigService : IJournalConfigService
return null; return null;
return int.TryParse(value, out var parsed) ? parsed : 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 readonly Lock SqliteInitLock = new();
private static bool _sqliteInitialized; private static bool _sqliteInitialized;
private static readonly IReadOnlyList<string> RequiredSchemaTables = 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; private readonly IJournalConfigService _config = config;
@ -129,6 +129,26 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
is_template INTEGER NOT NULL DEFAULT 0, is_template INTEGER NOT NULL DEFAULT 0,
updated_at TEXT 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 Microsoft.Extensions.DependencyInjection;
using Journal.AI;
using Journal.Core; using Journal.Core;
using Journal.Core.Services.Speech; using Journal.Core.Services.Speech;
using Journal.Core.Services.Sidecar; using Journal.Core.Services.Sidecar;
@ -9,6 +10,7 @@ Console.InputEncoding = System.Text.Encoding.UTF8;
var services = new ServiceCollection(); var services = new ServiceCollection();
services.AddFragmentServices(); services.AddFragmentServices();
services.AddLlamaSharpServices();
services.AddSingleton<IS2TService, LocalWhisperS2TService>(); services.AddSingleton<IS2TService, LocalWhisperS2TService>();
services.AddSingleton<Entry>(); services.AddSingleton<Entry>();
var provider = services.BuildServiceProvider(); var provider = services.BuildServiceProvider();

View File

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

View File

@ -16,8 +16,8 @@ public sealed class LocalWhisperS2TService : IS2TService, IDisposable
private const int MaxBufferedItems = 256; private const int MaxBufferedItems = 256;
private const int SilenceRmsThreshold = 150; private const int SilenceRmsThreshold = 150;
private readonly object _sync = new(); private readonly Lock _sync = new();
private readonly object _segmentLock = new(); private readonly Lock _segmentLock = new();
private readonly ConcurrentQueue<string> _transcripts = new(); private readonly ConcurrentQueue<string> _transcripts = new();
private WaveInEvent? _waveIn; 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.Sidecar;
global using Journal.Core.Services.Lists; global using Journal.Core.Services.Lists;
global using Journal.Core.Services.Todos; global using Journal.Core.Services.Todos;
global using Journal.Core.Services.Conversations;
global using Journal.Core.Services.Vault; global using Journal.Core.Services.Vault;

View File

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

View File

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

View File

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

View File

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

View File

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