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