feat: add AI coaching, conversation persistence, and LLamaSharp integration
- Add Journal.AI project with LLamaSharp-based AI service (Phi-3 model) - Implement coach sessions (daily check-in, evening review, weekly review) - Add conversation CRUD with SQLCipher persistence - AI chat with full conversation history for context-aware replies - Frontend: CoachPanel, AI stores, conversation stores, side panel UI - Conversation list with create, rename, and delete support - Fix Phi-3 output quality (system prompt leaking, token cleanup, JSON filtering) - Fix CREATEDRAFT kind override in coach sessions Co-Authored-By: Oz <oz-agent@warp.dev>
This commit is contained in:
parent
ee96c05d15
commit
192e6e3891
35
Journal.AI/Coach-Rules.txt
Normal file
35
Journal.AI/Coach-Rules.txt
Normal 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.
|
||||
20
Journal.AI/Daily-Check-In.txt
Normal file
20
Journal.AI/Daily-Check-In.txt
Normal 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.
|
||||
19
Journal.AI/Evening-Review.txt
Normal file
19
Journal.AI/Evening-Review.txt
Normal 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.
|
||||
25
Journal.AI/Journal.AI.csproj
Normal file
25
Journal.AI/Journal.AI.csproj
Normal 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>
|
||||
336
Journal.AI/LlamaSharpAiService.cs
Normal file
336
Journal.AI/LlamaSharpAiService.cs
Normal 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();
|
||||
}
|
||||
181
Journal.AI/LlamaSharpCoachService.cs
Normal file
181
Journal.AI/LlamaSharpCoachService.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
73
Journal.AI/ServiceCollectionExtensions.cs
Normal file
73
Journal.AI/ServiceCollectionExtensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
19
Journal.AI/Weekly-Review.txt
Normal file
19
Journal.AI/Weekly-Review.txt
Normal 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 week’s focus
|
||||
- 2-{{maxNextActions}} suggestedNextActions
|
||||
- patchProposal should only propose creating todos or a planning fragment, not editing old entries.
|
||||
222
Journal.App/src/lib/backend/ai.ts
Normal file
222
Journal.App/src/lib/backend/ai.ts
Normal 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);
|
||||
}
|
||||
166
Journal.App/src/lib/backend/conversations.ts
Normal file
166
Journal.App/src/lib/backend/conversations.ts
Normal file
@ -0,0 +1,166 @@
|
||||
import { sendCommand } from "./client";
|
||||
import { pickCase } from "./normalize";
|
||||
|
||||
// ── Public types ────────────────────────────────────────────────
|
||||
|
||||
export type ConversationDto = {
|
||||
id: string;
|
||||
title: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ConversationMessageDto = {
|
||||
id: string;
|
||||
role: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export type ConversationDetailDto = {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: ConversationMessageDto[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
};
|
||||
|
||||
export type ConversationChatResult = {
|
||||
userMessage: ConversationMessageDto;
|
||||
assistantMessage: ConversationMessageDto;
|
||||
};
|
||||
|
||||
// ── Raw (PascalCase) variants ───────────────────────────────────
|
||||
|
||||
type ConversationDtoRaw = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
Id?: string;
|
||||
Title?: string;
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
};
|
||||
|
||||
type ConversationMessageDtoRaw = {
|
||||
id?: string;
|
||||
role?: string;
|
||||
text?: string;
|
||||
createdAt?: string;
|
||||
Id?: string;
|
||||
Role?: string;
|
||||
Text?: string;
|
||||
CreatedAt?: string;
|
||||
};
|
||||
|
||||
type ConversationDetailDtoRaw = {
|
||||
id?: string;
|
||||
title?: string;
|
||||
messages?: ConversationMessageDtoRaw[];
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
Id?: string;
|
||||
Title?: string;
|
||||
Messages?: ConversationMessageDtoRaw[];
|
||||
CreatedAt?: string;
|
||||
UpdatedAt?: string;
|
||||
};
|
||||
|
||||
type ConversationChatResultRaw = {
|
||||
userMessage?: ConversationMessageDtoRaw;
|
||||
assistantMessage?: ConversationMessageDtoRaw;
|
||||
UserMessage?: ConversationMessageDtoRaw;
|
||||
AssistantMessage?: ConversationMessageDtoRaw;
|
||||
};
|
||||
|
||||
// ── Normalizers ─────────────────────────────────────────────────
|
||||
|
||||
function normalizeMessage(raw: ConversationMessageDtoRaw): ConversationMessageDto {
|
||||
return {
|
||||
id: pickCase(raw, "id", "Id", ""),
|
||||
role: pickCase(raw, "role", "Role", ""),
|
||||
text: pickCase(raw, "text", "Text", ""),
|
||||
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeConversation(raw: ConversationDtoRaw): ConversationDto {
|
||||
return {
|
||||
id: pickCase(raw, "id", "Id", ""),
|
||||
title: pickCase(raw, "title", "Title", ""),
|
||||
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
|
||||
updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", ""),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeDetail(raw: ConversationDetailDtoRaw): ConversationDetailDto {
|
||||
const msgs = pickCase(raw, "messages", "Messages", [] as ConversationMessageDtoRaw[]);
|
||||
return {
|
||||
id: pickCase(raw, "id", "Id", ""),
|
||||
title: pickCase(raw, "title", "Title", ""),
|
||||
messages: msgs.map(normalizeMessage),
|
||||
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
|
||||
updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", ""),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeChatResult(raw: ConversationChatResultRaw): ConversationChatResult {
|
||||
const userRaw = pickCase(raw, "userMessage", "UserMessage", {} as ConversationMessageDtoRaw);
|
||||
const assistantRaw = pickCase(raw, "assistantMessage", "AssistantMessage", {} as ConversationMessageDtoRaw);
|
||||
return {
|
||||
userMessage: normalizeMessage(userRaw),
|
||||
assistantMessage: normalizeMessage(assistantRaw),
|
||||
};
|
||||
}
|
||||
|
||||
// ── API functions ───────────────────────────────────────────────
|
||||
|
||||
export async function listConversations(): Promise<ConversationDto[]> {
|
||||
const data = await sendCommand<ConversationDtoRaw[]>({
|
||||
action: "conversations.list",
|
||||
payload: {},
|
||||
});
|
||||
return (data ?? []).map(normalizeConversation);
|
||||
}
|
||||
|
||||
export async function getConversation(id: string): Promise<ConversationDetailDto> {
|
||||
const data = await sendCommand<ConversationDetailDtoRaw>({
|
||||
action: "conversations.get",
|
||||
id,
|
||||
payload: {},
|
||||
});
|
||||
return normalizeDetail(data);
|
||||
}
|
||||
|
||||
export async function createConversation(title: string): Promise<ConversationDto> {
|
||||
const data = await sendCommand<ConversationDtoRaw>({
|
||||
action: "conversations.create",
|
||||
payload: { title },
|
||||
});
|
||||
return normalizeConversation(data);
|
||||
}
|
||||
|
||||
export async function updateConversation(id: string, title: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "conversations.update",
|
||||
id,
|
||||
payload: { title },
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteConversation(id: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "conversations.delete",
|
||||
id,
|
||||
payload: {},
|
||||
});
|
||||
}
|
||||
|
||||
export async function conversationChat(conversationId: string, prompt: string): Promise<ConversationChatResult> {
|
||||
const data = await sendCommand<ConversationChatResultRaw>({
|
||||
action: "conversations.chat",
|
||||
payload: { conversationId, prompt },
|
||||
});
|
||||
return normalizeChatResult(data);
|
||||
}
|
||||
691
Journal.App/src/lib/components/CoachPanel.svelte
Normal file
691
Journal.App/src/lib/components/CoachPanel.svelte
Normal file
@ -0,0 +1,691 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
import type { AiHealthDto, CoachPlanDto } from "$lib/backend/ai";
|
||||
import {
|
||||
activeConversationStore,
|
||||
sendConversationMessage,
|
||||
clearActiveConversation,
|
||||
} from "$lib/stores/conversations";
|
||||
|
||||
export let health: AiHealthDto | null = null;
|
||||
export let healthChecking = false;
|
||||
|
||||
export let coachBusy = false;
|
||||
export let coachError = "";
|
||||
export let coachPlan: CoachPlanDto | null = null;
|
||||
export let coachKind = "";
|
||||
|
||||
$: chatBusy = $activeConversationStore.busy;
|
||||
$: chatMessages = $activeConversationStore.messages;
|
||||
|
||||
let chatInput = "";
|
||||
|
||||
function handleChatSubmit() {
|
||||
const prompt = chatInput.trim();
|
||||
if (!prompt) return;
|
||||
chatInput = "";
|
||||
void sendConversationMessage(prompt);
|
||||
}
|
||||
|
||||
function handleChatKeydown(event: KeyboardEvent) {
|
||||
if (event.key === "Enter" && !event.shiftKey) {
|
||||
event.preventDefault();
|
||||
handleChatSubmit();
|
||||
}
|
||||
}
|
||||
|
||||
const kindLabels: Record<string, string> = {
|
||||
daily: "Daily Check-In",
|
||||
daily_checkin: "Daily Check-In",
|
||||
evening: "Evening Review",
|
||||
evening_review: "Evening Review",
|
||||
weekly: "Weekly Review",
|
||||
weekly_review: "Weekly Review",
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="coach-panel">
|
||||
<!-- Health banner -->
|
||||
<div
|
||||
class="health-banner"
|
||||
class:is-healthy={health?.healthy}
|
||||
class:is-unhealthy={health && !health.healthy}
|
||||
>
|
||||
{#if healthChecking}
|
||||
<span class="health-dot checking"></span>
|
||||
<span class="health-label">Checking AI status…</span>
|
||||
{:else if health}
|
||||
<span
|
||||
class="health-dot"
|
||||
class:healthy={health.healthy}
|
||||
class:unhealthy={!health.healthy}
|
||||
></span>
|
||||
<span class="health-label">
|
||||
{health.provider || "AI"} — {health.healthy ? "Ready" : "Unavailable"}
|
||||
</span>
|
||||
{#if health.message}
|
||||
<span class="health-message">{health.message}</span>
|
||||
{/if}
|
||||
{:else}
|
||||
<span class="health-dot unknown"></span>
|
||||
<span class="health-label">AI status unknown</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Coach result -->
|
||||
{#if coachBusy}
|
||||
<div class="coach-loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Running {kindLabels[coachKind] ?? "coach session"}…</p>
|
||||
<p class="loading-hint">
|
||||
This may take a moment while the model generates a response.
|
||||
</p>
|
||||
</div>
|
||||
{:else if coachError}
|
||||
<div class="coach-error">
|
||||
<span class="material-symbols-outlined error-icon">error</span>
|
||||
<p>{coachError}</p>
|
||||
</div>
|
||||
{:else if coachPlan}
|
||||
<article class="coach-plan">
|
||||
<header class="plan-header">
|
||||
<span class="plan-kind"
|
||||
>{kindLabels[coachPlan.kind] ?? coachPlan.kind}</span
|
||||
>
|
||||
<h2>{coachPlan.title}</h2>
|
||||
</header>
|
||||
|
||||
{#if coachPlan.summary}
|
||||
<section class="plan-section">
|
||||
<h3>Summary</h3>
|
||||
<p>{coachPlan.summary}</p>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if coachPlan.questions.length > 0}
|
||||
<section class="plan-section">
|
||||
<h3>Reflection Questions</h3>
|
||||
<ol class="plan-list">
|
||||
{#each coachPlan.questions as question}
|
||||
<li>{question}</li>
|
||||
{/each}
|
||||
</ol>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if coachPlan.suggestedNextActions.length > 0}
|
||||
<section class="plan-section">
|
||||
<h3>Suggested Next Actions</h3>
|
||||
<ul class="plan-list">
|
||||
{#each coachPlan.suggestedNextActions as action}
|
||||
<li>{action}</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if coachPlan.suggestedTags.length > 0}
|
||||
<section class="plan-section">
|
||||
<h3>Suggested Tags</h3>
|
||||
<div class="tag-list">
|
||||
{#each coachPlan.suggestedTags as tag}
|
||||
<span class="tag">{tag}</span>
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if coachPlan.evidence.length > 0}
|
||||
<section class="plan-section">
|
||||
<h3>Evidence</h3>
|
||||
<ul class="evidence-list">
|
||||
{#each coachPlan.evidence as item}
|
||||
<li>
|
||||
<span class="evidence-text">{item.text}</span>
|
||||
{#if item.recordId}
|
||||
<span class="evidence-ref">({item.recordId})</span>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{/if}
|
||||
|
||||
{#if coachPlan.patchProposal}
|
||||
<section class="plan-section">
|
||||
<h3>Patch Proposal</h3>
|
||||
<div class="patch-proposal">
|
||||
<span class="patch-kind">{coachPlan.patchProposal.kind}</span>
|
||||
{#if coachPlan.patchProposal.description}
|
||||
<p>{coachPlan.patchProposal.description}</p>
|
||||
{/if}
|
||||
{#if coachPlan.patchProposal.content}
|
||||
<pre class="patch-content">{coachPlan.patchProposal.content}</pre>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
</article>
|
||||
{:else if chatMessages.length === 0 && !chatBusy}
|
||||
<div class="coach-empty">
|
||||
<span class="material-symbols-outlined empty-icon">psychology</span>
|
||||
<p>
|
||||
Select a coaching session from the sidebar, or ask a question using
|
||||
the input below.
|
||||
</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Chat conversation -->
|
||||
{#if chatMessages.length > 0}
|
||||
<section class="chat-history">
|
||||
<h3>Conversation</h3>
|
||||
<div class="chat-messages">
|
||||
{#each chatMessages as msg}
|
||||
<div class="chat-msg" class:chat-user={msg.role === "user"} class:chat-assistant={msg.role === "assistant"} class:chat-error={msg.role === "error"}>
|
||||
<span class="chat-role">{msg.role === "user" ? "You" : msg.role === "error" ? "Error" : "AI"}</span>
|
||||
<div class="chat-text">{msg.text}</div>
|
||||
</div>
|
||||
{/each}
|
||||
{#if chatBusy}
|
||||
<div class="chat-msg chat-assistant">
|
||||
<span class="chat-role">AI</span>
|
||||
<div class="chat-thinking">
|
||||
<div class="spinner-sm"></div>
|
||||
<span>Thinking…</span>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
{:else if chatBusy}
|
||||
<div class="chat-loading">
|
||||
<div class="spinner"></div>
|
||||
<p>Thinking…</p>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<!-- Chat input (pinned at bottom) -->
|
||||
<div class="chat-input-bar">
|
||||
<div class="chat-input-row">
|
||||
<input
|
||||
type="text"
|
||||
bind:value={chatInput}
|
||||
placeholder="Ask a question…"
|
||||
on:keydown={handleChatKeydown}
|
||||
disabled={chatBusy}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
class="chat-send-btn"
|
||||
on:click={handleChatSubmit}
|
||||
disabled={chatBusy || !chatInput.trim()}
|
||||
aria-label="Send"
|
||||
>
|
||||
<span class="material-symbols-outlined">send</span>
|
||||
</button>
|
||||
</div>
|
||||
{#if chatMessages.length > 0}
|
||||
<button type="button" class="chat-clear-btn" on:click={clearActiveConversation}>
|
||||
Clear Chat
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.coach-panel {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 20px;
|
||||
padding: 24px 24px 0;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* ── Health banner ───────────────────────────────── */
|
||||
|
||||
.health-banner {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.health-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
|
||||
&.healthy {
|
||||
background: #4ade80;
|
||||
}
|
||||
|
||||
&.unhealthy {
|
||||
background: #e06c75;
|
||||
}
|
||||
|
||||
&.checking {
|
||||
background: var(--text-dim);
|
||||
animation: pulse 1s ease-in-out infinite;
|
||||
}
|
||||
|
||||
&.unknown {
|
||||
background: var(--text-dim);
|
||||
}
|
||||
}
|
||||
|
||||
.health-label {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.health-message {
|
||||
color: var(--text-dim);
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
/* ── Coach plan ──────────────────────────────────── */
|
||||
|
||||
.coach-plan {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.plan-header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
|
||||
h2 {
|
||||
font-size: 1.15rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
}
|
||||
|
||||
.plan-kind {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.plan-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
h3 {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.55;
|
||||
}
|
||||
}
|
||||
|
||||
.plan-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding-left: 20px;
|
||||
|
||||
li {
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.tag-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.tag {
|
||||
padding: 3px 10px;
|
||||
border-radius: 20px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-2);
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.evidence-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
list-style: none;
|
||||
|
||||
li {
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
font-size: 0.84rem;
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
.evidence-text {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.evidence-ref {
|
||||
color: var(--text-dim);
|
||||
font-size: 0.76rem;
|
||||
margin-left: 6px;
|
||||
}
|
||||
|
||||
.patch-proposal {
|
||||
padding: 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
|
||||
p {
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.5;
|
||||
}
|
||||
}
|
||||
|
||||
.patch-kind {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.patch-content {
|
||||
font-family: "Cascadia Code", "Fira Code", monospace;
|
||||
font-size: 0.8rem;
|
||||
color: var(--text-muted);
|
||||
background: var(--surface-2);
|
||||
padding: 10px 12px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
/* ── Chat conversation ────────────────────────────── */
|
||||
|
||||
.chat-history {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
border-top: 1px solid var(--border-soft);
|
||||
padding-top: 18px;
|
||||
|
||||
h3 {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.chat-messages {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.chat-msg {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 10px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
.chat-user {
|
||||
background: var(--surface-2);
|
||||
align-self: flex-end;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.chat-assistant {
|
||||
background: var(--surface-1);
|
||||
align-self: flex-start;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.chat-error {
|
||||
background: rgba(224, 108, 117, 0.08);
|
||||
border-color: #e06c75;
|
||||
align-self: flex-start;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.chat-role {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.chat-error .chat-role {
|
||||
color: #e06c75;
|
||||
}
|
||||
|
||||
.chat-text {
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.55;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.chat-thinking {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.spinner-sm {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid var(--border-soft);
|
||||
border-top-color: var(--text-muted);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
/* ── Empty / loading / error ─────────────────────── */
|
||||
|
||||
.coach-empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 60px 20px;
|
||||
text-align: center;
|
||||
|
||||
.empty-icon {
|
||||
font-size: 2.8rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-dim);
|
||||
max-width: 320px;
|
||||
}
|
||||
}
|
||||
|
||||
.coach-loading,
|
||||
.chat-loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
padding: 40px 20px;
|
||||
text-align: center;
|
||||
|
||||
p {
|
||||
font-size: 0.88rem;
|
||||
color: var(--text-muted);
|
||||
}
|
||||
}
|
||||
|
||||
.loading-hint {
|
||||
font-size: 0.78rem !important;
|
||||
color: var(--text-dim) !important;
|
||||
}
|
||||
|
||||
.coach-error {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid #e06c75;
|
||||
background: rgba(224, 108, 117, 0.08);
|
||||
|
||||
.error-icon {
|
||||
color: #e06c75;
|
||||
font-size: 1.1rem;
|
||||
flex-shrink: 0;
|
||||
margin-top: 1px;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
}
|
||||
|
||||
.spinner {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
border: 3px solid var(--border-soft);
|
||||
border-top-color: var(--text-muted);
|
||||
border-radius: 50%;
|
||||
animation: spin 0.7s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Chat input bar (pinned bottom) ─────────────── */
|
||||
|
||||
.chat-input-bar {
|
||||
position: sticky;
|
||||
bottom: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
padding: 12px 0 24px;
|
||||
background: var(--bg-editor);
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.chat-input-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 10px;
|
||||
background: var(--surface-1);
|
||||
padding: 6px 8px 6px 14px;
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 0.86rem;
|
||||
color: var(--text-primary);
|
||||
|
||||
&::placeholder {
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.chat-send-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 8px;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
border: 1px solid transparent;
|
||||
flex-shrink: 0;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-soft);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.chat-clear-btn {
|
||||
align-self: flex-start;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.74rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -4,6 +4,8 @@
|
||||
import ListEditor from "$lib/components/editor/ListEditor.svelte";
|
||||
import TodoEditor from "$lib/components/editor/TodoEditor.svelte";
|
||||
import MarkdownEditor from "$lib/components/editor/MarkdownEditor.svelte";
|
||||
import CoachPanel from "$lib/components/CoachPanel.svelte";
|
||||
import { aiStatusStore, coachStateStore } from "$lib/stores/ai";
|
||||
|
||||
export let activeSection = "entries";
|
||||
export let openDocumentId = "entries/daily-notes";
|
||||
@ -154,6 +156,15 @@
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if activeSection === "coach"}
|
||||
<CoachPanel
|
||||
health={$aiStatusStore.health}
|
||||
healthChecking={$aiStatusStore.checking}
|
||||
coachBusy={$coachStateStore.busy}
|
||||
coachError={$coachStateStore.error}
|
||||
coachPlan={$coachStateStore.plan}
|
||||
coachKind={$coachStateStore.kind}
|
||||
/>
|
||||
{:else if !openDocumentId}
|
||||
<div class="editor-empty">
|
||||
<span class="material-symbols-outlined empty-icon">edit_note</span>
|
||||
|
||||
@ -15,6 +15,7 @@
|
||||
{ id: "fragments", label: "Fragments", icon: "auto_stories" },
|
||||
{ id: "todos", label: "To-Do List", icon: "checklist" },
|
||||
{ id: "lists", label: "Lists", icon: "lists" },
|
||||
{ id: "coach", label: "Coach", icon: "psychology" },
|
||||
];
|
||||
|
||||
function selectItem(id: string) {
|
||||
|
||||
@ -32,6 +32,24 @@
|
||||
} from "$lib/stores/todos";
|
||||
import { vaultUnlocked } from "$lib/stores/session";
|
||||
import { extractEntryTags } from "$lib/utils/metadata";
|
||||
import {
|
||||
aiStatusStore,
|
||||
coachStateStore,
|
||||
checkAiHealth,
|
||||
runCoachSession,
|
||||
clearCoachPlan,
|
||||
} from "$lib/stores/ai";
|
||||
import type { CoachSessionPayload } from "$lib/backend/ai";
|
||||
import {
|
||||
conversationsStore,
|
||||
activeConversationStore,
|
||||
loadConversations,
|
||||
createNewConversation,
|
||||
openConversation,
|
||||
renameConversation,
|
||||
removeConversation,
|
||||
clearActiveConversation,
|
||||
} from "$lib/stores/conversations";
|
||||
|
||||
export let activeSection = "entries";
|
||||
export let activeDocumentId = "";
|
||||
@ -94,8 +112,90 @@
|
||||
fragments: "Fragments",
|
||||
todos: "To-Do List",
|
||||
lists: "Lists",
|
||||
coach: "Coach",
|
||||
};
|
||||
|
||||
// ── Coach state ──────────────────────────────────
|
||||
let coachHealthChecked = false;
|
||||
let conversationsLoaded = false;
|
||||
let editingConversationId = "";
|
||||
let editingConversationTitle = "";
|
||||
|
||||
const MAX_CONTEXT_ENTRIES = 5;
|
||||
const MAX_CONTEXT_FRAGMENTS = 10;
|
||||
|
||||
function gatherCoachContext(): CoachSessionPayload {
|
||||
const now = new Date();
|
||||
const dateLocal = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, "0")}-${String(now.getDate()).padStart(2, "0")}`;
|
||||
const recentEntries = $entriesStore
|
||||
.filter((e) => e.initialContent?.trim())
|
||||
.slice(0, MAX_CONTEXT_ENTRIES)
|
||||
.map((e) => e.initialContent);
|
||||
const recentFragments = $fragmentsStore
|
||||
.filter((f) => f.initialContent?.trim())
|
||||
.slice(0, MAX_CONTEXT_FRAGMENTS)
|
||||
.map((f) => f.initialContent);
|
||||
return { dateLocal, recentEntries, recentFragments };
|
||||
}
|
||||
|
||||
function handleCoachSession(kind: string) {
|
||||
const payload = gatherCoachContext();
|
||||
if (kind === "weekly") {
|
||||
const now = new Date();
|
||||
const weekDay = (now.getDay() + 6) % 7;
|
||||
const start = new Date(now);
|
||||
start.setDate(now.getDate() - weekDay);
|
||||
const end = new Date(start);
|
||||
end.setDate(start.getDate() + 6);
|
||||
payload.weekStartLocal = `${start.getFullYear()}-${String(start.getMonth() + 1).padStart(2, "0")}-${String(start.getDate()).padStart(2, "0")}`;
|
||||
payload.weekEndLocal = `${end.getFullYear()}-${String(end.getMonth() + 1).padStart(2, "0")}-${String(end.getDate()).padStart(2, "0")}`;
|
||||
}
|
||||
void runCoachSession(kind, payload);
|
||||
}
|
||||
|
||||
// ── Conversation helpers ──────────────────────────
|
||||
function ensureConversationsLoaded() {
|
||||
if (!conversationsLoaded) {
|
||||
conversationsLoaded = true;
|
||||
void loadConversations();
|
||||
}
|
||||
}
|
||||
|
||||
$: if (activeSection === "coach") {
|
||||
ensureConversationsLoaded();
|
||||
}
|
||||
|
||||
function formatConversationDefaultTitle(): string {
|
||||
const now = new Date();
|
||||
return now.toLocaleString(undefined, {
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
year: "numeric",
|
||||
hour: "numeric",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
||||
function startRenamingConversation(id: string, currentTitle: string) {
|
||||
editingConversationId = id;
|
||||
editingConversationTitle = currentTitle;
|
||||
}
|
||||
|
||||
function commitRename() {
|
||||
const trimmed = editingConversationTitle.trim();
|
||||
if (trimmed && editingConversationId) {
|
||||
void renameConversation(editingConversationId, trimmed);
|
||||
}
|
||||
editingConversationId = "";
|
||||
editingConversationTitle = "";
|
||||
}
|
||||
|
||||
function cancelRename() {
|
||||
editingConversationId = "";
|
||||
editingConversationTitle = "";
|
||||
}
|
||||
|
||||
|
||||
const today = new Date();
|
||||
let calendarYear = today.getFullYear();
|
||||
let calendarMonth = today.getMonth();
|
||||
@ -938,15 +1038,17 @@
|
||||
<span class="material-symbols-outlined">calendar_month</span>
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
class="panel-action"
|
||||
aria-label="Add item"
|
||||
title="Add item"
|
||||
on:click={handleAddItem}
|
||||
>
|
||||
<span class="material-symbols-outlined">add</span>
|
||||
</button>
|
||||
{#if activeSection !== "coach"}
|
||||
<button
|
||||
type="button"
|
||||
class="panel-action"
|
||||
aria-label="Add item"
|
||||
title="Add item"
|
||||
on:click={handleAddItem}
|
||||
>
|
||||
<span class="material-symbols-outlined">add</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@ -1085,6 +1187,145 @@
|
||||
Last refreshed: {calendarLastRefreshedAt || "Not yet"}
|
||||
</p>
|
||||
</div>
|
||||
{:else if activeSection === "coach"}
|
||||
<div class="coach-sidebar">
|
||||
<div class="coach-health">
|
||||
<button
|
||||
type="button"
|
||||
class="coach-health-btn"
|
||||
on:click={() => void checkAiHealth()}
|
||||
disabled={$aiStatusStore.checking}
|
||||
>
|
||||
<span
|
||||
class="health-indicator"
|
||||
class:healthy={$aiStatusStore.health?.healthy}
|
||||
class:unhealthy={$aiStatusStore.health &&
|
||||
!$aiStatusStore.health.healthy}
|
||||
class:checking={$aiStatusStore.checking}
|
||||
></span>
|
||||
{$aiStatusStore.checking
|
||||
? "Checking…"
|
||||
: $aiStatusStore.health
|
||||
? $aiStatusStore.health.healthy
|
||||
? "AI Ready"
|
||||
: "AI Unavailable"
|
||||
: "Check AI Status"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="panel-subsection">
|
||||
<h3>Coaching Sessions</h3>
|
||||
<div class="coach-session-buttons">
|
||||
<button
|
||||
type="button"
|
||||
class="coach-session-btn"
|
||||
on:click={() => handleCoachSession("daily")}
|
||||
disabled={$coachStateStore.busy}
|
||||
>
|
||||
<span class="material-symbols-outlined">wb_sunny</span>
|
||||
Daily Check-In
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="coach-session-btn"
|
||||
on:click={() => handleCoachSession("evening")}
|
||||
disabled={$coachStateStore.busy}
|
||||
>
|
||||
<span class="material-symbols-outlined">dark_mode</span>
|
||||
Evening Review
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="coach-session-btn"
|
||||
on:click={() => handleCoachSession("weekly")}
|
||||
disabled={$coachStateStore.busy}
|
||||
>
|
||||
<span class="material-symbols-outlined">date_range</span>
|
||||
Weekly Review
|
||||
</button>
|
||||
</div>
|
||||
{#if $coachStateStore.plan}
|
||||
<button
|
||||
type="button"
|
||||
class="coach-clear-btn"
|
||||
on:click={clearCoachPlan}
|
||||
>
|
||||
Clear Result
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="panel-subsection">
|
||||
<div class="subsection-header">
|
||||
<h3>Conversations</h3>
|
||||
<button
|
||||
type="button"
|
||||
class="subsection-action"
|
||||
on:click={() => {
|
||||
clearActiveConversation();
|
||||
void createNewConversation(formatConversationDefaultTitle());
|
||||
}}
|
||||
aria-label="New conversation"
|
||||
>
|
||||
<span class="material-symbols-outlined">add</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if $conversationsStore.busy}
|
||||
<p class="section-copy">Loading…</p>
|
||||
{:else if $conversationsStore.items.length === 0}
|
||||
<p class="section-copy">No conversations yet.</p>
|
||||
{:else}
|
||||
<ul class="panel-list">
|
||||
{#each $conversationsStore.items as conv}
|
||||
<li class:is-active={conv.id === $activeConversationStore.id}>
|
||||
{#if editingConversationId === conv.id}
|
||||
<input
|
||||
type="text"
|
||||
class="rename-input"
|
||||
bind:value={editingConversationTitle}
|
||||
on:keydown={(e) => {
|
||||
if (e.key === "Enter") commitRename();
|
||||
if (e.key === "Escape") cancelRename();
|
||||
}}
|
||||
on:blur={commitRename}
|
||||
autofocus
|
||||
/>
|
||||
{:else}
|
||||
<button
|
||||
type="button"
|
||||
class="item-label"
|
||||
on:click={() => void openConversation(conv.id)}
|
||||
>
|
||||
{conv.title}
|
||||
</button>
|
||||
<div class="item-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="item-action"
|
||||
on:click|stopPropagation={() =>
|
||||
startRenamingConversation(conv.id, conv.title)}
|
||||
aria-label="Rename conversation"
|
||||
>
|
||||
<span class="material-symbols-outlined">edit</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="item-action item-action-danger"
|
||||
on:click|stopPropagation={() =>
|
||||
void removeConversation(conv.id)}
|
||||
aria-label="Delete conversation"
|
||||
>
|
||||
<span class="material-symbols-outlined">delete</span>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="panel-search">
|
||||
<span class="material-symbols-outlined">search</span>
|
||||
@ -1597,4 +1838,166 @@
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Coach sidebar ────────────────────────────────── */
|
||||
|
||||
.coach-sidebar {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
overflow: auto;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.coach-health {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.coach-health-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 8px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.8rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.health-indicator {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
flex-shrink: 0;
|
||||
background: var(--text-dim);
|
||||
|
||||
&.healthy {
|
||||
background: #4ade80;
|
||||
}
|
||||
|
||||
&.unhealthy {
|
||||
background: #e06c75;
|
||||
}
|
||||
|
||||
&.checking {
|
||||
animation: coach-pulse 1s ease-in-out infinite;
|
||||
}
|
||||
}
|
||||
|
||||
.coach-session-buttons {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.coach-session-btn {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
width: 100%;
|
||||
padding: 9px 12px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
color: var(--text-muted);
|
||||
font-size: 0.82rem;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
background: var(--bg-hover);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
&:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: default;
|
||||
}
|
||||
}
|
||||
|
||||
.coach-clear-btn {
|
||||
align-self: flex-start;
|
||||
padding: 4px 10px;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
font-size: 0.74rem;
|
||||
cursor: pointer;
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.subsection-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.subsection-action {
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
border-radius: 6px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
|
||||
.material-symbols-outlined {
|
||||
font-size: 1rem;
|
||||
}
|
||||
|
||||
&:hover {
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-soft);
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
}
|
||||
|
||||
.rename-input {
|
||||
width: 100%;
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-primary);
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--accent, #6b8afd);
|
||||
border-radius: 6px;
|
||||
padding: 6px 9px;
|
||||
outline: none;
|
||||
}
|
||||
|
||||
|
||||
@keyframes coach-pulse {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 0.4;
|
||||
}
|
||||
50% {
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
140
Journal.App/src/lib/stores/ai.ts
Normal file
140
Journal.App/src/lib/stores/ai.ts
Normal 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: [] });
|
||||
}
|
||||
222
Journal.App/src/lib/stores/conversations.ts
Normal file
222
Journal.App/src/lib/stores/conversations.ts
Normal file
@ -0,0 +1,222 @@
|
||||
import { writable, get } from "svelte/store";
|
||||
import {
|
||||
listConversations as listConversationsApi,
|
||||
getConversation as getConversationApi,
|
||||
createConversation as createConversationApi,
|
||||
deleteConversation as deleteConversationApi,
|
||||
updateConversation as updateConversationApi,
|
||||
conversationChat as conversationChatApi,
|
||||
type ConversationDto,
|
||||
type ConversationMessageDto,
|
||||
} from "$lib/backend/conversations";
|
||||
|
||||
// ── Store shapes ────────────────────────────────────────────────
|
||||
|
||||
type ConversationsState = {
|
||||
items: ConversationDto[];
|
||||
busy: boolean;
|
||||
error: string;
|
||||
};
|
||||
|
||||
type ActiveConversationState = {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: ConversationMessageDto[];
|
||||
busy: boolean;
|
||||
error: string;
|
||||
};
|
||||
|
||||
// ── Stores ──────────────────────────────────────────────────────
|
||||
|
||||
export const conversationsStore = writable<ConversationsState>({
|
||||
items: [],
|
||||
busy: false,
|
||||
error: "",
|
||||
});
|
||||
|
||||
export const activeConversationStore = writable<ActiveConversationState>({
|
||||
id: "",
|
||||
title: "",
|
||||
messages: [],
|
||||
busy: false,
|
||||
error: "",
|
||||
});
|
||||
|
||||
// ── Actions ─────────────────────────────────────────────────────
|
||||
|
||||
export async function loadConversations(): Promise<void> {
|
||||
conversationsStore.update((s) => ({ ...s, busy: true, error: "" }));
|
||||
try {
|
||||
const items = await listConversationsApi();
|
||||
conversationsStore.set({ items, busy: false, error: "" });
|
||||
} catch (error) {
|
||||
conversationsStore.update((s) => ({
|
||||
...s,
|
||||
busy: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export async function createNewConversation(
|
||||
title: string,
|
||||
): Promise<string | null> {
|
||||
try {
|
||||
const conv = await createConversationApi(title);
|
||||
conversationsStore.update((s) => ({
|
||||
...s,
|
||||
items: [conv, ...s.items],
|
||||
}));
|
||||
// Auto-open the new conversation
|
||||
activeConversationStore.set({
|
||||
id: conv.id,
|
||||
title: conv.title,
|
||||
messages: [],
|
||||
busy: false,
|
||||
error: "",
|
||||
});
|
||||
return conv.id;
|
||||
} catch (error) {
|
||||
conversationsStore.update((s) => ({
|
||||
...s,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export async function openConversation(id: string): Promise<void> {
|
||||
activeConversationStore.update((s) => ({
|
||||
...s,
|
||||
id,
|
||||
busy: true,
|
||||
error: "",
|
||||
}));
|
||||
try {
|
||||
const detail = await getConversationApi(id);
|
||||
activeConversationStore.set({
|
||||
id: detail.id,
|
||||
title: detail.title,
|
||||
messages: detail.messages,
|
||||
busy: false,
|
||||
error: "",
|
||||
});
|
||||
} catch (error) {
|
||||
activeConversationStore.update((s) => ({
|
||||
...s,
|
||||
busy: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendConversationMessage(
|
||||
prompt: string,
|
||||
): Promise<void> {
|
||||
const state = get(activeConversationStore);
|
||||
if (!state.id) {
|
||||
// Auto-create a conversation from the first message
|
||||
const title =
|
||||
prompt.length > 40 ? prompt.slice(0, 37) + "..." : prompt;
|
||||
const convId = await createNewConversation(title);
|
||||
if (!convId) return;
|
||||
}
|
||||
|
||||
const currentId = get(activeConversationStore).id;
|
||||
if (!currentId) return;
|
||||
|
||||
// Optimistically add user message
|
||||
const tempUserMsg: ConversationMessageDto = {
|
||||
id: `temp-${Date.now()}`,
|
||||
role: "user",
|
||||
text: prompt,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
activeConversationStore.update((s) => ({
|
||||
...s,
|
||||
messages: [...s.messages, tempUserMsg],
|
||||
busy: true,
|
||||
error: "",
|
||||
}));
|
||||
|
||||
try {
|
||||
const result = await conversationChatApi(currentId, prompt);
|
||||
activeConversationStore.update((s) => ({
|
||||
...s,
|
||||
busy: false,
|
||||
// Replace temp user message + add assistant message
|
||||
messages: [
|
||||
...s.messages.filter((m) => m.id !== tempUserMsg.id),
|
||||
result.userMessage,
|
||||
result.assistantMessage,
|
||||
],
|
||||
}));
|
||||
// Update conversation list (updated_at changes)
|
||||
void loadConversations();
|
||||
} catch (error) {
|
||||
const errorMsg: ConversationMessageDto = {
|
||||
id: `error-${Date.now()}`,
|
||||
role: "error",
|
||||
text: error instanceof Error ? error.message : String(error),
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
activeConversationStore.update((s) => ({
|
||||
...s,
|
||||
busy: false,
|
||||
messages: [...s.messages, errorMsg],
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export async function renameConversation(
|
||||
id: string,
|
||||
title: string,
|
||||
): Promise<void> {
|
||||
try {
|
||||
await updateConversationApi(id, title);
|
||||
conversationsStore.update((s) => ({
|
||||
...s,
|
||||
items: s.items.map((c) => (c.id === id ? { ...c, title } : c)),
|
||||
}));
|
||||
const active = get(activeConversationStore);
|
||||
if (active.id === id) {
|
||||
activeConversationStore.update((s) => ({ ...s, title }));
|
||||
}
|
||||
} catch (error) {
|
||||
conversationsStore.update((s) => ({
|
||||
...s,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export async function removeConversation(id: string): Promise<void> {
|
||||
try {
|
||||
await deleteConversationApi(id);
|
||||
conversationsStore.update((s) => ({
|
||||
...s,
|
||||
items: s.items.filter((c) => c.id !== id),
|
||||
}));
|
||||
// If this was the active conversation, clear it
|
||||
const active = get(activeConversationStore);
|
||||
if (active.id === id) {
|
||||
clearActiveConversation();
|
||||
}
|
||||
} catch (error) {
|
||||
conversationsStore.update((s) => ({
|
||||
...s,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
export function clearActiveConversation(): void {
|
||||
activeConversationStore.set({
|
||||
id: "",
|
||||
title: "",
|
||||
messages: [],
|
||||
busy: false,
|
||||
error: "",
|
||||
});
|
||||
}
|
||||
@ -10,6 +10,7 @@ const startupViews = [
|
||||
"fragments",
|
||||
"todos",
|
||||
"lists",
|
||||
"coach",
|
||||
] as const;
|
||||
const defaultStartupView = "entries";
|
||||
export type StartupView = (typeof startupViews)[number];
|
||||
|
||||
@ -109,8 +109,8 @@
|
||||
case "calendar":
|
||||
case "fragments":
|
||||
case "todos":
|
||||
case "lists":
|
||||
case "entries":
|
||||
case "coach":
|
||||
return value;
|
||||
default:
|
||||
return "entries";
|
||||
@ -126,6 +126,7 @@
|
||||
case "fragments":
|
||||
case "todos":
|
||||
case "lists":
|
||||
case "coach":
|
||||
return normalized;
|
||||
default:
|
||||
return null;
|
||||
|
||||
32
Journal.Core/Dtos/CoachDtos.cs
Normal file
32
Journal.Core/Dtos/CoachDtos.cs
Normal 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);
|
||||
@ -20,6 +20,9 @@ internal sealed record AiSummarizeEntryPayload(string Content, string? FileStem
|
||||
internal sealed record AiSummarizeAllPayload(List<string>? Entries);
|
||||
internal sealed record AiChatPayload(string Prompt);
|
||||
internal sealed record AiEmbedPayload(string Content);
|
||||
internal sealed record CoachDailyPayload(string? DateLocal = null, CoachPreferencesDto? Preferences = null, List<string>? RecentEntries = null, List<string>? RecentFragments = null);
|
||||
internal sealed record CoachEveningPayload(string? DateLocal = null, CoachPreferencesDto? Preferences = null, List<string>? RecentEntries = null, List<string>? RecentFragments = null);
|
||||
internal sealed record CoachWeeklyPayload(string? WeekStartLocal = null, string? WeekEndLocal = null, CoachPreferencesDto? Preferences = null, List<string>? RecentEntries = null, List<string>? RecentFragments = null);
|
||||
internal sealed record SpeechTranscribePayload(
|
||||
string? AudioBase64 = null,
|
||||
string? Audio_Base64 = null,
|
||||
@ -30,6 +33,9 @@ internal sealed record SpeechTranscribePayload(
|
||||
int? SimulateDelayMs = null,
|
||||
int? Simulate_Delay_Ms = null);
|
||||
internal sealed record S2TPollPayload(int? MaxItems = null);
|
||||
internal sealed record ConversationCreatePayload(string Title);
|
||||
internal sealed record ConversationUpdatePayload(string? Title);
|
||||
internal sealed record ConversationChatPayload(string ConversationId, string Prompt);
|
||||
internal sealed record SearchEntriesPayload(
|
||||
string? Query = null,
|
||||
string? Section = null,
|
||||
|
||||
28
Journal.Core/Dtos/ConversationDtos.cs
Normal file
28
Journal.Core/Dtos/ConversationDtos.cs
Normal 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);
|
||||
@ -9,6 +9,7 @@ using Journal.Core.Services.Database;
|
||||
using Journal.Core.Services.Entries;
|
||||
using Journal.Core.Services.Fragments;
|
||||
using Journal.Core.Services.Lists;
|
||||
using Journal.Core.Services.Conversations;
|
||||
using Journal.Core.Services.Logging;
|
||||
using Journal.Core.Services.Speech;
|
||||
using Journal.Core.Services.Todos;
|
||||
@ -29,6 +30,8 @@ public class Entry(
|
||||
IEntryFileService entryFiles,
|
||||
IListService lists,
|
||||
ITodoService todos,
|
||||
ICoachService coach,
|
||||
IConversationService conversations,
|
||||
CommandLogger logger)
|
||||
{
|
||||
private readonly IFragmentService _fragments = fragments;
|
||||
@ -43,6 +46,8 @@ public class Entry(
|
||||
private readonly IEntryFileService _entryFiles = entryFiles;
|
||||
private readonly IListService _lists = lists;
|
||||
private readonly ITodoService _todos = todos;
|
||||
private readonly ICoachService _coach = coach;
|
||||
private readonly IConversationService _conversations = conversations;
|
||||
private readonly CommandLogger _logger = logger;
|
||||
private static readonly HashSet<string> VaultSyncActions = new(StringComparer.Ordinal)
|
||||
{
|
||||
@ -61,7 +66,11 @@ public class Entry(
|
||||
"todos.delete",
|
||||
"todos.items.create",
|
||||
"todos.items.update",
|
||||
"todos.items.delete"
|
||||
"todos.items.delete",
|
||||
"conversations.create",
|
||||
"conversations.update",
|
||||
"conversations.delete",
|
||||
"conversations.chat"
|
||||
};
|
||||
private static readonly JsonSerializerOptions JsonOptions = new()
|
||||
{
|
||||
@ -303,6 +312,84 @@ public class Entry(
|
||||
return Error("Missing or invalid payload");
|
||||
result = await _ai.EmbedAsync(embedPayload.Content);
|
||||
break;
|
||||
|
||||
// ── Coach ─────────────────────────────────────────
|
||||
case "ai.coach.daily":
|
||||
var coachDailyPayload = DeserializePayload<CoachDailyPayload>(cmd.Payload);
|
||||
result = await _coach.DailyCheckInAsync(new CoachContextDto(
|
||||
DateLocal: coachDailyPayload?.DateLocal ?? DateTime.Now.ToString("yyyy-MM-dd"),
|
||||
RecentEntries: coachDailyPayload?.RecentEntries,
|
||||
RecentFragments: coachDailyPayload?.RecentFragments,
|
||||
Preferences: coachDailyPayload?.Preferences));
|
||||
break;
|
||||
case "ai.coach.evening":
|
||||
var coachEveningPayload = DeserializePayload<CoachEveningPayload>(cmd.Payload);
|
||||
result = await _coach.EveningReviewAsync(new CoachContextDto(
|
||||
DateLocal: coachEveningPayload?.DateLocal ?? DateTime.Now.ToString("yyyy-MM-dd"),
|
||||
RecentEntries: coachEveningPayload?.RecentEntries,
|
||||
RecentFragments: coachEveningPayload?.RecentFragments,
|
||||
Preferences: coachEveningPayload?.Preferences));
|
||||
break;
|
||||
case "ai.coach.weekly":
|
||||
var coachWeeklyPayload = DeserializePayload<CoachWeeklyPayload>(cmd.Payload);
|
||||
var now = DateTime.Now;
|
||||
var weekStart = now.AddDays(-(int)now.DayOfWeek + (int)DayOfWeek.Monday);
|
||||
result = await _coach.WeeklyReviewAsync(new CoachContextDto(
|
||||
DateLocal: now.ToString("yyyy-MM-dd"),
|
||||
WeekStartLocal: coachWeeklyPayload?.WeekStartLocal ?? weekStart.ToString("yyyy-MM-dd"),
|
||||
WeekEndLocal: coachWeeklyPayload?.WeekEndLocal ?? weekStart.AddDays(6).ToString("yyyy-MM-dd"),
|
||||
RecentEntries: coachWeeklyPayload?.RecentEntries,
|
||||
RecentFragments: coachWeeklyPayload?.RecentFragments,
|
||||
Preferences: coachWeeklyPayload?.Preferences));
|
||||
break;
|
||||
|
||||
// ── Conversations ──────────────────────────────────
|
||||
case "conversations.list":
|
||||
result = _conversations.GetAll();
|
||||
break;
|
||||
case "conversations.get":
|
||||
if (!Guid.TryParse(cmd.Id, out var getConvId))
|
||||
return Error("Invalid or missing id");
|
||||
result = _conversations.GetById(getConvId);
|
||||
break;
|
||||
case "conversations.create":
|
||||
var convCreatePayload = DeserializePayload<ConversationCreatePayload>(cmd.Payload);
|
||||
if (convCreatePayload is null || string.IsNullOrWhiteSpace(convCreatePayload.Title))
|
||||
return Error("Missing or invalid payload");
|
||||
result = _conversations.Create(new CreateConversationDto(convCreatePayload.Title));
|
||||
break;
|
||||
case "conversations.update":
|
||||
if (!Guid.TryParse(cmd.Id, out var updateConvId))
|
||||
return Error("Invalid or missing id");
|
||||
var convUpdatePayload = DeserializePayload<ConversationUpdatePayload>(cmd.Payload);
|
||||
if (convUpdatePayload is null)
|
||||
return Error("Missing or invalid payload");
|
||||
result = _conversations.Update(updateConvId, new UpdateConversationDto(convUpdatePayload.Title));
|
||||
break;
|
||||
case "conversations.delete":
|
||||
if (!Guid.TryParse(cmd.Id, out var deleteConvId))
|
||||
return Error("Invalid or missing id");
|
||||
result = _conversations.Remove(deleteConvId);
|
||||
break;
|
||||
case "conversations.chat":
|
||||
var convChatPayload = DeserializePayload<ConversationChatPayload>(cmd.Payload);
|
||||
if (convChatPayload is null || string.IsNullOrWhiteSpace(convChatPayload.Prompt)
|
||||
|| !Guid.TryParse(convChatPayload.ConversationId, out var chatConvId))
|
||||
return Error("Missing or invalid payload");
|
||||
// Save user message
|
||||
var userMsg = _conversations.AddMessage(chatConvId, "user", convChatPayload.Prompt);
|
||||
// Build history from existing messages
|
||||
var history = _conversations.GetMessages(chatConvId)
|
||||
.Where(m => m.Id != userMsg.Id)
|
||||
.Select(m => (m.Role, m.Text))
|
||||
.ToList();
|
||||
// Get AI response with full conversation context
|
||||
var aiResponse = await _ai.ChatWithHistoryAsync(history, convChatPayload.Prompt);
|
||||
// Save AI response
|
||||
var assistantMsg = _conversations.AddMessage(chatConvId, "assistant", aiResponse);
|
||||
result = new { userMessage = userMsg, assistantMessage = assistantMsg };
|
||||
break;
|
||||
|
||||
case "speech.devices.list":
|
||||
result = await _speech.ListDevicesAsync();
|
||||
break;
|
||||
|
||||
33
Journal.Core/Models/Conversation.cs
Normal file
33
Journal.Core/Models/Conversation.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
38
Journal.Core/Models/ConversationMessage.cs
Normal file
38
Journal.Core/Models/ConversationMessage.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@ -24,4 +24,5 @@ public sealed record JournalConfig(
|
||||
string AiProvider,
|
||||
string PythonExecutable,
|
||||
string PythonAiSidecarPath,
|
||||
int AiSidecarTimeoutMs);
|
||||
int AiSidecarTimeoutMs,
|
||||
string GgufModelPath);
|
||||
|
||||
14
Journal.Core/Repositories/IConversationRepository.cs
Normal file
14
Journal.Core/Repositories/IConversationRepository.cs
Normal 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);
|
||||
}
|
||||
217
Journal.Core/Repositories/SqliteConversationRepository.cs
Normal file
217
Journal.Core/Repositories/SqliteConversationRepository.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
@ -9,6 +9,7 @@ using Journal.Core.Services.Lists;
|
||||
using Journal.Core.Services.Logging;
|
||||
using Journal.Core.Services.Sidecar;
|
||||
using Journal.Core.Services.Speech;
|
||||
using Journal.Core.Services.Conversations;
|
||||
using Journal.Core.Services.Todos;
|
||||
using Journal.Core.Services.Vault;
|
||||
|
||||
@ -65,6 +66,9 @@ public static class ServiceCollectionExtensions
|
||||
services.AddSingleton<IListService, ListService>();
|
||||
services.AddSingleton<ITodoRepository, SqliteTodoRepository>();
|
||||
services.AddSingleton<ITodoService, TodoService>();
|
||||
services.AddSingleton<ICoachService>(new DisabledCoachService());
|
||||
services.AddSingleton<IConversationRepository, SqliteConversationRepository>();
|
||||
services.AddSingleton<IConversationService, ConversationService>();
|
||||
services.AddSingleton<CommandLogger>();
|
||||
services.AddSingleton<SidecarCli>();
|
||||
return services;
|
||||
|
||||
@ -20,6 +20,9 @@ public sealed class DisabledAiService(string provider, string message = "AI prov
|
||||
public Task<string> ChatAsync(string prompt, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_message);
|
||||
|
||||
public Task<string> ChatWithHistoryAsync(IReadOnlyList<(string Role, string Text)> history, string prompt, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult(_message);
|
||||
|
||||
public Task<IReadOnlyList<double>> EmbedAsync(string content, CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<double>>([]);
|
||||
}
|
||||
|
||||
26
Journal.Core/Services/Ai/DisabledCoachService.cs
Normal file
26
Journal.Core/Services/Ai/DisabledCoachService.cs
Normal 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: []);
|
||||
}
|
||||
@ -8,5 +8,6 @@ public interface IAiService
|
||||
Task<string> SummarizeEntryAsync(string content, string? fileStem = null, CancellationToken cancellationToken = default);
|
||||
Task<string> SummarizeAllAsync(IReadOnlyList<string> entries, CancellationToken cancellationToken = default);
|
||||
Task<string> ChatAsync(string prompt, CancellationToken cancellationToken = default);
|
||||
Task<string> ChatWithHistoryAsync(IReadOnlyList<(string Role, string Text)> history, string prompt, CancellationToken cancellationToken = default);
|
||||
Task<IReadOnlyList<double>> EmbedAsync(string content, CancellationToken cancellationToken = default);
|
||||
}
|
||||
|
||||
10
Journal.Core/Services/Ai/ICoachService.cs
Normal file
10
Journal.Core/Services/Ai/ICoachService.cs
Normal 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);
|
||||
}
|
||||
@ -61,6 +61,13 @@ public sealed class PythonSidecarAiService : IAiService
|
||||
return data?.GetString() ?? "";
|
||||
}
|
||||
|
||||
public Task<string> ChatWithHistoryAsync(IReadOnlyList<(string Role, string Text)> history,
|
||||
string prompt, CancellationToken cancellationToken = default)
|
||||
{
|
||||
// Python sidecar does not support multi-turn — fall back to single-turn
|
||||
return ChatAsync(prompt, cancellationToken);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<double>> EmbedAsync(string content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
|
||||
@ -21,8 +21,8 @@ public sealed class JournalConfigService : IJournalConfigService
|
||||
if (nlpBackend is not ("auto" or "spacy" or "fallback"))
|
||||
nlpBackend = "auto";
|
||||
|
||||
var aiProvider = (Environment.GetEnvironmentVariable("JOURNAL_AI_PROVIDER") ?? "none").Trim().ToLowerInvariant();
|
||||
if (aiProvider is not ("none" or "python-sidecar"))
|
||||
var aiProvider = (Environment.GetEnvironmentVariable("JOURNAL_AI_PROVIDER") ?? "llamasharp").Trim().ToLowerInvariant();
|
||||
if (aiProvider is not ("none" or "python-sidecar" or "llamasharp"))
|
||||
aiProvider = "none";
|
||||
|
||||
var pythonExecutable = Environment.GetEnvironmentVariable("JOURNAL_PYTHON_EXE");
|
||||
@ -57,7 +57,8 @@ public sealed class JournalConfigService : IJournalConfigService
|
||||
AiProvider: aiProvider,
|
||||
PythonExecutable: pythonExecutable,
|
||||
PythonAiSidecarPath: pythonAiSidecarPath,
|
||||
AiSidecarTimeoutMs: aiSidecarTimeoutMs);
|
||||
AiSidecarTimeoutMs: aiSidecarTimeoutMs,
|
||||
GgufModelPath: ResolveGgufModelPath(projectRoot));
|
||||
}
|
||||
|
||||
private static string ResolveProjectRoot()
|
||||
@ -101,5 +102,22 @@ public sealed class JournalConfigService : IJournalConfigService
|
||||
return null;
|
||||
return int.TryParse(value, out var parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
private static string ResolveGgufModelPath(string projectRoot)
|
||||
{
|
||||
var fromEnv = Environment.GetEnvironmentVariable("JOURNAL_GGUF_MODEL_PATH");
|
||||
if (!string.IsNullOrWhiteSpace(fromEnv))
|
||||
return Path.GetFullPath(fromEnv);
|
||||
|
||||
var modelsDir = Path.Combine(projectRoot, "models");
|
||||
if (Directory.Exists(modelsDir))
|
||||
{
|
||||
var first = Directory.EnumerateFiles(modelsDir, "*.gguf").FirstOrDefault();
|
||||
if (first is not null)
|
||||
return Path.GetFullPath(first);
|
||||
}
|
||||
|
||||
return Path.Combine(modelsDir, "model.gguf");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
68
Journal.Core/Services/Conversations/ConversationService.cs
Normal file
68
Journal.Core/Services/Conversations/ConversationService.cs
Normal 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)];
|
||||
}
|
||||
}
|
||||
14
Journal.Core/Services/Conversations/IConversationService.cs
Normal file
14
Journal.Core/Services/Conversations/IConversationService.cs
Normal 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);
|
||||
}
|
||||
@ -15,7 +15,7 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
|
||||
private static readonly Lock SqliteInitLock = new();
|
||||
private static bool _sqliteInitialized;
|
||||
private static readonly IReadOnlyList<string> RequiredSchemaTables =
|
||||
["entries", "sections", "fragments", "tags", "fragment_tags", "lists", "todo_lists", "todo_items", "entry_documents"];
|
||||
["entries", "sections", "fragments", "tags", "fragment_tags", "lists", "todo_lists", "todo_items", "entry_documents", "conversations", "conversation_messages"];
|
||||
|
||||
private readonly IJournalConfigService _config = config;
|
||||
|
||||
@ -129,6 +129,26 @@ public sealed class JournalDatabaseService(IJournalConfigService config) : IJour
|
||||
is_template INTEGER NOT NULL DEFAULT 0,
|
||||
updated_at TEXT
|
||||
);
|
||||
""",
|
||||
["conversations"] = """
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT UNIQUE,
|
||||
title TEXT NOT NULL,
|
||||
created_at TEXT,
|
||||
updated_at TEXT
|
||||
);
|
||||
""",
|
||||
["conversation_messages"] = """
|
||||
CREATE TABLE IF NOT EXISTS conversation_messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
guid TEXT UNIQUE,
|
||||
conversation_id INTEGER NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
text TEXT NOT NULL,
|
||||
created_at TEXT,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations (id)
|
||||
);
|
||||
"""
|
||||
};
|
||||
}
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Journal.AI;
|
||||
using Journal.Core;
|
||||
using Journal.Core.Services.Speech;
|
||||
using Journal.Core.Services.Sidecar;
|
||||
@ -9,6 +10,7 @@ Console.InputEncoding = System.Text.Encoding.UTF8;
|
||||
|
||||
var services = new ServiceCollection();
|
||||
services.AddFragmentServices();
|
||||
services.AddLlamaSharpServices();
|
||||
services.AddSingleton<IS2TService, LocalWhisperS2TService>();
|
||||
services.AddSingleton<Entry>();
|
||||
var provider = services.BuildServiceProvider();
|
||||
|
||||
@ -10,6 +10,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Journal.AI\Journal.AI.csproj" />
|
||||
<ProjectReference Include="..\Journal.Core\Journal.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@ -16,8 +16,8 @@ public sealed class LocalWhisperS2TService : IS2TService, IDisposable
|
||||
private const int MaxBufferedItems = 256;
|
||||
private const int SilenceRmsThreshold = 150;
|
||||
|
||||
private readonly object _sync = new();
|
||||
private readonly object _segmentLock = new();
|
||||
private readonly Lock _sync = new();
|
||||
private readonly Lock _segmentLock = new();
|
||||
private readonly ConcurrentQueue<string> _transcripts = new();
|
||||
|
||||
private WaveInEvent? _waveIn;
|
||||
|
||||
@ -15,4 +15,5 @@ global using Journal.Core.Services.Speech;
|
||||
global using Journal.Core.Services.Sidecar;
|
||||
global using Journal.Core.Services.Lists;
|
||||
global using Journal.Core.Services.Todos;
|
||||
global using Journal.Core.Services.Conversations;
|
||||
global using Journal.Core.Services.Vault;
|
||||
|
||||
@ -25,9 +25,12 @@ internal static partial class Program
|
||||
config,
|
||||
new DisabledAiService("none"),
|
||||
new DisabledSpeechBridgeService("none"),
|
||||
new DisabledS2TService(),
|
||||
new EntryFileService(entryRepo),
|
||||
new ListService(new SqliteListRepository(session)),
|
||||
new TodoService(new SqliteTodoRepository(session)),
|
||||
new DisabledCoachService(),
|
||||
new ConversationService(new SqliteConversationRepository(session)),
|
||||
new CommandLogger());
|
||||
}
|
||||
|
||||
|
||||
@ -437,9 +437,12 @@ internal static partial class Program
|
||||
config,
|
||||
new DisabledAiService("none"),
|
||||
new DisabledSpeechBridgeService("none"),
|
||||
new DisabledS2TService(),
|
||||
entryFiles,
|
||||
new ListService(new SqliteListRepository(session)),
|
||||
new TodoService(new SqliteTodoRepository(session)),
|
||||
new DisabledCoachService(),
|
||||
new ConversationService(new SqliteConversationRepository(session)),
|
||||
new CommandLogger());
|
||||
|
||||
try
|
||||
|
||||
@ -7,6 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\Journal.AI\Journal.AI.csproj" />
|
||||
<ProjectReference Include="..\Journal.Core\Journal.Core.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Journal.AI;
|
||||
using Journal.Core;
|
||||
using Microsoft.Extensions.FileProviders;
|
||||
|
||||
@ -17,6 +18,7 @@ var webDistPath = ResolveWebDist(repoRoot);
|
||||
var builder = WebApplication.CreateBuilder(args);
|
||||
|
||||
builder.Services.AddFragmentServices();
|
||||
builder.Services.AddLlamaSharpServices();
|
||||
builder.Services.AddSingleton<Entry>();
|
||||
builder.Services.AddSingleton(new SidecarRootState(repoRoot));
|
||||
builder.Services.AddSingleton(new WebUiState(webDistPath));
|
||||
|
||||
@ -1,4 +1,5 @@
|
||||
<Solution>
|
||||
<Project Path="Journal.AI/Journal.AI.csproj" />
|
||||
<Project Path="Journal.Core/Journal.Core.csproj" />
|
||||
<Project Path="Journal.Sidecar/Journal.Sidecar.csproj" />
|
||||
<Project Path="Journal.SmokeTests/Journal.SmokeTests.csproj" />
|
||||
|
||||
@ -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.
|
||||
Loading…
x
Reference in New Issue
Block a user