Compare commits
No commits in common. "alpha" and "master" have entirely different histories.
16
.gitignore
vendored
@ -44,3 +44,19 @@ logs/
|
||||
|
||||
# macOS
|
||||
.DS_Store
|
||||
|
||||
# Node
|
||||
node_modules/
|
||||
|
||||
# OTHER
|
||||
.just/
|
||||
journalapp.exe
|
||||
journalapp(1).exe
|
||||
.cache/
|
||||
Journal.DevTool/scripts/__pycache__/
|
||||
.sdt/
|
||||
devtool.backup.json
|
||||
Journal.App/node_modules.old/
|
||||
scripts/__pycache__/
|
||||
Journal.WebGateway/cookies.txt
|
||||
output.7z
|
||||
|
||||
7
Directory.Build.props
Normal file
@ -0,0 +1,7 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
</Project>
|
||||
17
Directory.Packages.props
Normal file
@ -0,0 +1,17 @@
|
||||
<Project>
|
||||
<PropertyGroup>
|
||||
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Data.Sqlite.Core" Version="10.0.3" />
|
||||
<PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="10.0.3" />
|
||||
<PackageVersion Include="SQLitePCLRaw.bundle_e_sqlcipher" Version="2.1.11" />
|
||||
<PackageVersion Include="NAudio" Version="2.2.1" />
|
||||
<PackageVersion Include="Whisper.net" Version="1.9.0" />
|
||||
<PackageVersion Include="Whisper.net.Runtime" Version="1.9.0" />
|
||||
<PackageVersion Include="LLamaSharp" Version="0.25.0" />
|
||||
<PackageVersion Include="LLamaSharp.Backend.Cpu" Version="0.25.0" />
|
||||
<PackageVersion Include="LLamaSharp.Backend.Vulkan" Version="0.25.0" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
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
@ -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
@ -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.
|
||||
20
Journal.AI/Journal.AI.csproj
Normal file
@ -0,0 +1,20 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="LLamaSharp" />
|
||||
<PackageReference Include="LLamaSharp.Backend.Cpu" />
|
||||
<PackageReference Include="LLamaSharp.Backend.Vulkan" />
|
||||
</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>
|
||||
329
Journal.AI/LlamaSharpAiService.cs
Normal file
@ -0,0 +1,329 @@
|
||||
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.GpuLayerCount;
|
||||
|
||||
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);
|
||||
|
||||
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 ONLY a single valid JSON object. " +
|
||||
$"Do NOT write any text, explanation, or commentary before or after the JSON. " +
|
||||
$"Output MUST start with {{ and end with }}.",
|
||||
maxTokens: 2048, temperature: 0.2f, 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)
|
||||
{
|
||||
if (File.Exists(_configuredModelPath))
|
||||
return _configuredModelPath;
|
||||
|
||||
var defaultPath = GetDefaultModelPath();
|
||||
if (File.Exists(defaultPath))
|
||||
return defaultPath;
|
||||
|
||||
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 Task<string> RunSessionAsync(string prompt, string systemPrompt,
|
||||
int maxTokens, CancellationToken cancellationToken)
|
||||
=> RunSessionAsync(prompt, systemPrompt, maxTokens, temperature: 0.7f, cancellationToken);
|
||||
|
||||
private async Task<string> RunSessionAsync(string prompt, string systemPrompt,
|
||||
int maxTokens, float temperature, CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
|
||||
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 = temperature
|
||||
}
|
||||
};
|
||||
|
||||
var sb = new StringBuilder();
|
||||
|
||||
await foreach (var token in executor.InferAsync(fullPrompt, inferenceParams, cancellationToken))
|
||||
{
|
||||
sb.Append(token);
|
||||
}
|
||||
|
||||
return StripSpecialTokens(sb.ToString());
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
private static readonly Regex RoleMarkerRegex = MyRegex();
|
||||
|
||||
private static string CleanChatResponse(string raw)
|
||||
{
|
||||
var text = StripSpecialTokens(raw);
|
||||
|
||||
text = RoleMarkerRegex.Replace(text, "");
|
||||
text = text.Replace("**", "");
|
||||
text = MyRegex2().Replace(text, "\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();
|
||||
[GeneratedRegex(@"\n{3,}")]
|
||||
private static partial Regex MyRegex2();
|
||||
}
|
||||
229
Journal.AI/LlamaSharpCoachService.cs
Normal file
@ -0,0 +1,229 @@
|
||||
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);
|
||||
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)
|
||||
{
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
var firstBrace = text.IndexOf('{');
|
||||
if (firstBrace >= 0)
|
||||
{
|
||||
var repaired = TryRepairJson(text[firstBrace..]);
|
||||
if (repaired is not null)
|
||||
return repaired;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static string? TryRepairJson(string text)
|
||||
{
|
||||
var trimmed = text.TrimEnd();
|
||||
var lastUseful = trimmed.Length - 1;
|
||||
while (lastUseful >= 0)
|
||||
{
|
||||
var ch = trimmed[lastUseful];
|
||||
if (ch is '}' or ']' or '"' or ',' or ':' or '{' or '[' || char.IsDigit(ch)
|
||||
|| ch is 't' or 'r' or 'u' or 'e' or 'f' or 'a' or 'l' or 's' or 'n')
|
||||
break;
|
||||
lastUseful--;
|
||||
}
|
||||
|
||||
if (lastUseful < 0) return null;
|
||||
trimmed = trimmed[..(lastUseful + 1)];
|
||||
|
||||
if (trimmed.EndsWith(','))
|
||||
trimmed = trimmed[..^1];
|
||||
|
||||
var openBraces = 0;
|
||||
var openBrackets = 0;
|
||||
var inString = false;
|
||||
var escape = false;
|
||||
|
||||
foreach (var ch in trimmed)
|
||||
{
|
||||
if (escape) { escape = false; continue; }
|
||||
if (ch == '\\' && inString) { escape = true; continue; }
|
||||
if (ch == '"') { inString = !inString; continue; }
|
||||
if (inString) continue;
|
||||
|
||||
switch (ch)
|
||||
{
|
||||
case '{': openBraces++; break;
|
||||
case '}': openBraces--; break;
|
||||
case '[': openBrackets++; break;
|
||||
case ']': openBrackets--; break;
|
||||
}
|
||||
}
|
||||
|
||||
if (openBraces <= 0 && openBrackets <= 0) return null;
|
||||
|
||||
var sb = new StringBuilder(trimmed);
|
||||
for (var i = 0; i < openBrackets; i++) sb.Append(']');
|
||||
for (var i = 0; i < openBraces; i++) sb.Append('}');
|
||||
|
||||
var repaired = sb.ToString();
|
||||
return TryValidateJson(repaired) ? repaired : 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)) ?? 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();
|
||||
}
|
||||
}
|
||||
57
Journal.AI/ServiceCollectionExtensions.cs
Normal file
@ -0,0 +1,57 @@
|
||||
using Journal.Core.Services.Ai;
|
||||
using Journal.Core.Services.Config;
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
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
@ -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.
|
||||
8
Journal.App/.prettierignore
Normal file
@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
build/
|
||||
.svelte-kit/
|
||||
.vscode/
|
||||
dist/
|
||||
coverage/
|
||||
target/
|
||||
src-tauri/target/
|
||||
@ -18,12 +18,12 @@ npm run tauri dev # Tauri desktop window (connects to dev server)
|
||||
|
||||
## Build Targets
|
||||
|
||||
| Command | Output | Use case |
|
||||
|---------|--------|----------|
|
||||
| `npm run build` | `Journal.App/build/` | Web bundle for `Journal.WebGateway` |
|
||||
| `.\scripts\publish-app.ps1 -Target web` | `Journal.App/build/` | Same, via script |
|
||||
| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles none` | `src-tauri/target/release/journalapp.exe` | Raw desktop exe |
|
||||
| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles nsis` | NSIS installer | Packaged installer |
|
||||
| Command | Output | Use case |
|
||||
| ------------------------------------------------------------ | ----------------------------------------- | ----------------------------------- |
|
||||
| `npm run build` | `Journal.App/build/` | Web bundle for `Journal.WebGateway` |
|
||||
| `.\scripts\publish-app.ps1 -Target web` | `Journal.App/build/` | Same, via script |
|
||||
| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles none` | `src-tauri/target/release/journalapp.exe` | Raw desktop exe |
|
||||
| `.\scripts\publish-app.ps1 -Target tauri -TauriBundles nsis` | NSIS installer | Packaged installer |
|
||||
|
||||
## Frontend State Management
|
||||
|
||||
@ -31,13 +31,13 @@ Svelte stores are the source of truth for all feature state.
|
||||
|
||||
### Current Stores
|
||||
|
||||
| Store file | State exports | Notes |
|
||||
|-----------|---------------|-------|
|
||||
| `src/lib/stores/entries.ts` | `entriesStore` | Entry list, `getDefaultEntry`, `createEntryDraft` |
|
||||
| `src/lib/stores/fragments.ts` | `fragmentsStore` | Fragment CRUD + parse/serialize helpers |
|
||||
| `src/lib/stores/todos.ts` | `todoListsStore`, `todosStore` | Todo list and item CRUD |
|
||||
| `src/lib/stores/lists.ts` | `listsStore` | Generic list CRUD, `createListDraft` |
|
||||
| `src/lib/stores/settings.ts` | `settingsTags`, `settingsFragmentTypes` | Tag/type config |
|
||||
| Store file | State exports | Notes |
|
||||
| ----------------------------- | --------------------------------------- | ------------------------------------------------- |
|
||||
| `src/lib/stores/entries.ts` | `entriesStore` | Entry list, `getDefaultEntry`, `createEntryDraft` |
|
||||
| `src/lib/stores/fragments.ts` | `fragmentsStore` | Fragment CRUD + parse/serialize helpers |
|
||||
| `src/lib/stores/todos.ts` | `todoListsStore`, `todosStore` | Todo list and item CRUD |
|
||||
| `src/lib/stores/lists.ts` | `listsStore` | Generic list CRUD, `createListDraft` |
|
||||
| `src/lib/stores/settings.ts` | `settingsTags`, `settingsFragmentTypes` | Tag/type config |
|
||||
|
||||
### Store-First Rule
|
||||
|
||||
@ -47,14 +47,14 @@ Svelte stores are the source of truth for all feature state.
|
||||
|
||||
## Tauri Commands (Rust → Frontend)
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `sidecar_command` | Forward a `CommandEnvelope` to `Journal.Sidecar` stdin/stdout and return parsed JSON |
|
||||
| `get_sidecar_root` | Get currently resolved sidecar root path |
|
||||
| `set_sidecar_root` | Override root path (saved to `settings.json`, restarts sidecar) |
|
||||
| `get_ui_settings` | Load tag/fragment-type settings |
|
||||
| `set_ui_settings` | Persist tag/fragment-type settings |
|
||||
| `shutdown` | Stop sidecar, exit app |
|
||||
| Command | Description |
|
||||
| ------------------ | ------------------------------------------------------------------------------------ |
|
||||
| `sidecar_command` | Forward a `CommandEnvelope` to `Journal.Sidecar` stdin/stdout and return parsed JSON |
|
||||
| `get_sidecar_root` | Get currently resolved sidecar root path |
|
||||
| `set_sidecar_root` | Override root path (saved to `settings.json`, restarts sidecar) |
|
||||
| `get_ui_settings` | Load tag/fragment-type settings |
|
||||
| `set_ui_settings` | Persist tag/fragment-type settings |
|
||||
| `shutdown` | Stop sidecar, exit app |
|
||||
|
||||
## Sidecar Path Resolution
|
||||
|
||||
|
||||
@ -6,24 +6,31 @@
|
||||
"scripts": {
|
||||
"dev": "vite dev",
|
||||
"build": "vite build",
|
||||
"tauri:prebuild": "node ./scripts/tauri-prebuild.mjs",
|
||||
"preview": "vite preview",
|
||||
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
|
||||
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
|
||||
"tauri": "tauri"
|
||||
"tauri": "tauri",
|
||||
"format": "prettier --write .",
|
||||
"format:check": "prettier --check ."
|
||||
},
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tauri-apps/api": "^2",
|
||||
"@tauri-apps/plugin-opener": "^2"
|
||||
"@tauri-apps/plugin-dialog": "^2.6.0",
|
||||
"@tauri-apps/plugin-opener": "^2",
|
||||
"tauri-plugin-mic-recorder-api": "^2.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@sveltejs/adapter-static": "^3.0.6",
|
||||
"@sveltejs/kit": "^2.9.0",
|
||||
"@sveltejs/vite-plugin-svelte": "^5.0.0",
|
||||
"@tauri-apps/cli": "^2",
|
||||
"prettier": "^3.8.1",
|
||||
"prettier-plugin-svelte": "^3.5.0",
|
||||
"svelte": "^5.0.0",
|
||||
"svelte-check": "^4.0.0",
|
||||
"typescript": "~5.6.2",
|
||||
"vite": "^6.0.3",
|
||||
"@tauri-apps/cli": "^2"
|
||||
"vite": "^6.0.3"
|
||||
}
|
||||
}
|
||||
|
||||
11
Journal.App/prettier.config.cjs
Normal file
@ -0,0 +1,11 @@
|
||||
module.exports = {
|
||||
plugins: ["prettier-plugin-svelte"],
|
||||
overrides: [
|
||||
{
|
||||
files: "*.svelte",
|
||||
options: {
|
||||
parser: "svelte",
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
127
Journal.App/scripts/tauri-prebuild.mjs
Normal file
@ -0,0 +1,127 @@
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { copyFileSync, cpSync, existsSync, mkdirSync, readdirSync, rmSync, statSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = path.dirname(__filename);
|
||||
const appRoot = path.resolve(__dirname, "..");
|
||||
const repoRoot = path.resolve(appRoot, "..");
|
||||
|
||||
const sidecarProject = path.join(
|
||||
repoRoot,
|
||||
"Journal.Sidecar",
|
||||
"Journal.Sidecar.csproj",
|
||||
);
|
||||
const publishOutputDir = path.join(repoRoot, "output");
|
||||
const tauriBinDir = path.join(appRoot, "src-tauri", "bin");
|
||||
|
||||
function runtimeForCurrentPlatform() {
|
||||
const arch = process.arch;
|
||||
if (process.platform === "win32") {
|
||||
if (arch === "arm64") return "win-arm64";
|
||||
return "win-x64";
|
||||
}
|
||||
if (process.platform === "linux") {
|
||||
if (arch === "arm64") return "linux-arm64";
|
||||
return "linux-x64";
|
||||
}
|
||||
if (process.platform === "darwin") {
|
||||
if (arch === "arm64") return "osx-arm64";
|
||||
return "osx-x64";
|
||||
}
|
||||
throw new Error(
|
||||
`Unsupported platform '${process.platform}' for sidecar publish.`,
|
||||
);
|
||||
}
|
||||
|
||||
function sidecarFileName() {
|
||||
return process.platform === "win32"
|
||||
? "Journal.Sidecar.exe"
|
||||
: "Journal.Sidecar";
|
||||
}
|
||||
|
||||
function publishProject(projectPath, runtime) {
|
||||
const publishArgs = [
|
||||
"publish",
|
||||
projectPath,
|
||||
"-c",
|
||||
"Release",
|
||||
"-r",
|
||||
runtime,
|
||||
"--self-contained",
|
||||
"-p:PublishSingleFile=true",
|
||||
"-p:IncludeNativeLibrariesForSelfExtract=false",
|
||||
"-p:RestoreIgnoreFailedSources=true",
|
||||
"-p:NuGetAudit=false",
|
||||
"-p:ErrorOnDuplicatePublishOutputFiles=false",
|
||||
"-o",
|
||||
publishOutputDir,
|
||||
];
|
||||
|
||||
const publish = spawnSync("dotnet", publishArgs, {
|
||||
cwd: repoRoot,
|
||||
stdio: "inherit",
|
||||
});
|
||||
|
||||
if (publish.error) {
|
||||
throw publish.error;
|
||||
}
|
||||
if (publish.status !== 0) {
|
||||
process.exit(publish.status ?? 1);
|
||||
}
|
||||
}
|
||||
|
||||
function stageOutput(fileName) {
|
||||
const publishedBinary = path.join(publishOutputDir, fileName);
|
||||
|
||||
if (!existsSync(publishedBinary)) {
|
||||
throw new Error(`Published binary not found: ${publishedBinary}`);
|
||||
}
|
||||
|
||||
mkdirSync(tauriBinDir, { recursive: true });
|
||||
|
||||
const skipExts = new Set([".pdb", ".metal"]);
|
||||
for (const entry of readdirSync(publishOutputDir)) {
|
||||
const ext = path.extname(entry).toLowerCase();
|
||||
if (skipExts.has(ext)) continue;
|
||||
const src = path.join(publishOutputDir, entry);
|
||||
const dest = path.join(tauriBinDir, entry);
|
||||
if (entry === "runtimes") {
|
||||
// Only copy the runtimes subdirectory matching the target platform
|
||||
stageRuntimes(src, dest, runtime);
|
||||
} else if (statSync(src).isDirectory()) {
|
||||
cpSync(src, dest, { recursive: true, force: true });
|
||||
} else {
|
||||
copyFileSync(src, dest);
|
||||
}
|
||||
}
|
||||
console.log(`Staged sidecar + native libs to: ${tauriBinDir}`);
|
||||
}
|
||||
|
||||
function stageRuntimes(runtimesDir, destDir, rid) {
|
||||
// Only copy the subdirectory that matches our runtime identifier (e.g. win-x64)
|
||||
const ridDir = path.join(runtimesDir, rid);
|
||||
if (!existsSync(ridDir)) {
|
||||
console.warn(`No runtimes found for ${rid}, skipping runtimes/`);
|
||||
return;
|
||||
}
|
||||
// Remove stale runtimes from previous builds
|
||||
if (existsSync(destDir)) {
|
||||
rmSync(destDir, { recursive: true, force: true });
|
||||
}
|
||||
const destRidDir = path.join(destDir, rid);
|
||||
cpSync(ridDir, destRidDir, { recursive: true, force: true });
|
||||
console.log(`Copied runtimes/${rid} (skipped ${readdirSync(runtimesDir).length - 1} other platform(s))`);
|
||||
}
|
||||
|
||||
const runtime = runtimeForCurrentPlatform();
|
||||
const sidecarName = sidecarFileName();
|
||||
|
||||
console.log(
|
||||
`Publishing sidecar for ${process.platform}/${process.arch} (${runtime})...`,
|
||||
);
|
||||
|
||||
console.log("Publishing Journal.Sidecar...");
|
||||
publishProject(sidecarProject, runtime);
|
||||
stageOutput(sidecarName);
|
||||
793
Journal.App/src-tauri/Cargo.lock
generated
@ -2,7 +2,7 @@
|
||||
name = "journalapp"
|
||||
version = "0.1.0"
|
||||
description = "A Tauri App"
|
||||
authors = ["Stan"]
|
||||
authors = ["Stan", "J. Schmidt"]
|
||||
edition = "2021"
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
@ -19,7 +19,10 @@ tauri-build = { version = "2", features = [] }
|
||||
|
||||
[dependencies]
|
||||
tauri = { version = "2", features = [] }
|
||||
tauri-plugin-dialog = "2"
|
||||
tauri-plugin-opener = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tokio = { version = "1", features = ["process", "io-util", "sync"] }
|
||||
tokio = { version = "1", features = ["process", "io-util", "sync", "time"] }
|
||||
tauri-plugin-mic-recorder = "2.0.0"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
|
||||
|
||||
@ -5,6 +5,8 @@
|
||||
"windows": ["main"],
|
||||
"permissions": [
|
||||
"core:default",
|
||||
"opener:default"
|
||||
"dialog:default",
|
||||
"opener:default",
|
||||
"mic-recorder:default"
|
||||
]
|
||||
}
|
||||
|
||||
|
Before Width: | Height: | Size: 3.4 KiB After Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 6.8 KiB After Width: | Height: | Size: 23 KiB |
|
Before Width: | Height: | Size: 974 B After Width: | Height: | Size: 995 B |
BIN
Journal.App/src-tauri/icons/64x64.png
Normal file
|
After Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 2.8 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 3.8 KiB After Width: | Height: | Size: 8.1 KiB |
|
Before Width: | Height: | Size: 3.9 KiB After Width: | Height: | Size: 9.0 KiB |
|
Before Width: | Height: | Size: 7.6 KiB After Width: | Height: | Size: 28 KiB |
|
Before Width: | Height: | Size: 903 B After Width: | Height: | Size: 935 B |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 34 KiB |
|
Before Width: | Height: | Size: 1.3 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 2.0 KiB After Width: | Height: | Size: 2.8 KiB |
|
Before Width: | Height: | Size: 2.4 KiB After Width: | Height: | Size: 3.9 KiB |
|
Before Width: | Height: | Size: 1.5 KiB After Width: | Height: | Size: 1.7 KiB |
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<foreground android:drawable="@mipmap/ic_launcher_foreground"/>
|
||||
<background android:drawable="@color/ic_launcher_background"/>
|
||||
</adaptive-icon>
|
||||
BIN
Journal.App/src-tauri/icons/android/mipmap-hdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 10 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
BIN
Journal.App/src-tauri/icons/android/mipmap-mdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 1.4 KiB |
|
After Width: | Height: | Size: 5.3 KiB |
|
After Width: | Height: | Size: 1.6 KiB |
BIN
Journal.App/src-tauri/icons/android/mipmap-xhdpi/ic_launcher.png
Normal file
|
After Width: | Height: | Size: 3.7 KiB |
|
After Width: | Height: | Size: 17 KiB |
|
After Width: | Height: | Size: 4.3 KiB |
|
After Width: | Height: | Size: 6.7 KiB |
|
After Width: | Height: | Size: 38 KiB |
|
After Width: | Height: | Size: 7.9 KiB |
|
After Width: | Height: | Size: 11 KiB |
|
After Width: | Height: | Size: 70 KiB |
|
After Width: | Height: | Size: 13 KiB |
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<resources>
|
||||
<color name="ic_launcher_background">#fff</color>
|
||||
</resources>
|
||||
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 30 KiB |
|
Before Width: | Height: | Size: 14 KiB After Width: | Height: | Size: 104 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-20x20@1x.png
Normal file
|
After Width: | Height: | Size: 552 B |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-20x20@2x-1.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-20x20@2x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-20x20@3x.png
Normal file
|
After Width: | Height: | Size: 2.1 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-29x29@1x.png
Normal file
|
After Width: | Height: | Size: 815 B |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-29x29@2x-1.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-29x29@2x.png
Normal file
|
After Width: | Height: | Size: 2.0 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-29x29@3x.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-40x40@1x.png
Normal file
|
After Width: | Height: | Size: 1.2 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-40x40@2x-1.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-40x40@2x.png
Normal file
|
After Width: | Height: | Size: 3.2 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-40x40@3x.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-512@2x.png
Normal file
|
After Width: | Height: | Size: 477 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-60x60@2x.png
Normal file
|
After Width: | Height: | Size: 5.9 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-60x60@3x.png
Normal file
|
After Width: | Height: | Size: 11 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-76x76@1x.png
Normal file
|
After Width: | Height: | Size: 3.0 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-76x76@2x.png
Normal file
|
After Width: | Height: | Size: 8.6 KiB |
BIN
Journal.App/src-tauri/icons/ios/AppIcon-83.5x83.5@2x.png
Normal file
|
After Width: | Height: | Size: 10 KiB |
@ -5,7 +5,7 @@ use std::env;
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::Stdio;
|
||||
use tauri::Manager;
|
||||
use tauri::{Emitter, Manager};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::process::{Child, ChildStdin, ChildStdout, Command};
|
||||
use tokio::sync::Mutex;
|
||||
@ -28,7 +28,7 @@ struct CommandEnvelope {
|
||||
|
||||
const DEFAULT_SETTINGS_TAGS: &[&str] = &["Personal", "Work", "Ideas", "Journal"];
|
||||
const DEFAULT_FRAGMENT_TYPES: &[&str] = &["Quote", "Snippet", "Reference"];
|
||||
|
||||
const DEFAULT_STARTUP_VIEW: &str = "entries";
|
||||
#[derive(Deserialize, Serialize)]
|
||||
struct AppSettings {
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
@ -37,6 +37,8 @@ struct AppSettings {
|
||||
tags: Vec<String>,
|
||||
#[serde(default = "default_fragment_types")]
|
||||
fragment_types: Vec<String>,
|
||||
#[serde(default = "default_startup_view")]
|
||||
default_startup_view: String,
|
||||
}
|
||||
|
||||
impl Default for AppSettings {
|
||||
@ -45,6 +47,7 @@ impl Default for AppSettings {
|
||||
sidecar_root: None,
|
||||
tags: default_settings_tags(),
|
||||
fragment_types: default_fragment_types(),
|
||||
default_startup_view: default_startup_view(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -63,6 +66,10 @@ fn default_fragment_types() -> Vec<String> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn default_startup_view() -> String {
|
||||
DEFAULT_STARTUP_VIEW.to_string()
|
||||
}
|
||||
|
||||
fn normalize_items(values: Vec<String>, fallback: &[&str]) -> Vec<String> {
|
||||
let mut seen = HashSet::new();
|
||||
let mut normalized = Vec::new();
|
||||
@ -84,15 +91,30 @@ fn normalize_items(values: Vec<String>, fallback: &[&str]) -> Vec<String> {
|
||||
normalized
|
||||
}
|
||||
|
||||
fn normalize_startup_view(value: Option<String>) -> String {
|
||||
let normalized = value
|
||||
.unwrap_or_else(default_startup_view)
|
||||
.trim()
|
||||
.to_lowercase();
|
||||
match normalized.as_str() {
|
||||
"entries" | "calendar" | "fragments" | "todos" | "lists" => normalized,
|
||||
_ => default_startup_view(),
|
||||
}
|
||||
}
|
||||
|
||||
struct ManagedSidecar {
|
||||
child: Child,
|
||||
stdin: ChildStdin,
|
||||
stdout: BufReader<ChildStdout>,
|
||||
}
|
||||
|
||||
struct ManagedSpeechProcess {
|
||||
poll_task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl ManagedSidecar {
|
||||
fn start(root: &Path) -> Result<Self, String> {
|
||||
let sidecar_path = resolve_sidecar_path(root)?;
|
||||
fn start(root: &Path, resource_dir: Option<&Path>) -> Result<Self, String> {
|
||||
let sidecar_path = resolve_sidecar_path(root, resource_dir)?;
|
||||
let mut cmd = Command::new(sidecar_path);
|
||||
cmd.stdin(Stdio::piped())
|
||||
.stdout(Stdio::piped())
|
||||
@ -163,10 +185,18 @@ impl Drop for ManagedSidecar {
|
||||
fn drop(&mut self) {}
|
||||
}
|
||||
|
||||
impl ManagedSpeechProcess {
|
||||
fn is_running(&self) -> bool {
|
||||
!self.poll_task.is_finished()
|
||||
}
|
||||
}
|
||||
|
||||
struct SidecarState {
|
||||
process: Mutex<Option<ManagedSidecar>>,
|
||||
speech_process: Mutex<Option<ManagedSpeechProcess>>,
|
||||
root_override: Mutex<Option<PathBuf>>,
|
||||
config_path: PathBuf,
|
||||
resource_dir: Option<PathBuf>,
|
||||
}
|
||||
|
||||
fn load_settings(path: &Path) -> AppSettings {
|
||||
@ -182,17 +212,77 @@ fn save_settings(path: &Path, settings: &AppSettings) -> Result<(), String> {
|
||||
fs::write(path, json).map_err(|e| format!("Failed to save settings: {e}"))
|
||||
}
|
||||
|
||||
fn auto_detect_root() -> Result<PathBuf, String> {
|
||||
let mut current =
|
||||
env::current_dir().map_err(|err| format!("Unable to read current dir: {err}"))?;
|
||||
loop {
|
||||
if current.join("Journal.Sidecar").exists() {
|
||||
return Ok(current);
|
||||
fn candidate_roots(root: &Path) -> Vec<PathBuf> {
|
||||
let mut candidates = Vec::new();
|
||||
let push_unique = |value: PathBuf, items: &mut Vec<PathBuf>| {
|
||||
if !items.iter().any(|existing| existing == &value) {
|
||||
items.push(value);
|
||||
}
|
||||
if !current.pop() {
|
||||
return Err("Unable to locate repository root containing Journal.Sidecar.".to_string());
|
||||
};
|
||||
|
||||
push_unique(root.to_path_buf(), &mut candidates);
|
||||
|
||||
if let Some(name) = root.file_name().and_then(|v| v.to_str()) {
|
||||
if name.eq_ignore_ascii_case("output") {
|
||||
if let Some(parent) = root.parent() {
|
||||
push_unique(parent.to_path_buf(), &mut candidates);
|
||||
}
|
||||
} else if name.eq_ignore_ascii_case("webgateway") {
|
||||
if let Some(parent) = root.parent() {
|
||||
push_unique(parent.to_path_buf(), &mut candidates);
|
||||
}
|
||||
if let Some(parent) = root.parent().and_then(|v| v.parent()) {
|
||||
push_unique(parent.to_path_buf(), &mut candidates);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
candidates
|
||||
}
|
||||
|
||||
fn detect_root_from(start: &Path) -> Option<PathBuf> {
|
||||
let mut current = start.to_path_buf();
|
||||
loop {
|
||||
let has_repo_markers = current.join("Journal.Sidecar").exists()
|
||||
|| current.join("Journal.Core").exists()
|
||||
|| current.join("Journal.slnx").exists()
|
||||
|| current.join("Journal.Sidecar.exe").exists()
|
||||
|| current.join("Journal.Sidecar").is_file();
|
||||
if has_repo_markers {
|
||||
return Some(current);
|
||||
}
|
||||
|
||||
if !current.pop() {
|
||||
return None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn auto_detect_root() -> Result<PathBuf, String> {
|
||||
if let Some(env_root) = env::var_os("JOURNAL_PROJECT_ROOT") {
|
||||
let env_path = PathBuf::from(env_root);
|
||||
if env_path.exists() {
|
||||
return Ok(env_path);
|
||||
}
|
||||
}
|
||||
|
||||
let mut starts = Vec::new();
|
||||
if let Ok(current) = env::current_dir() {
|
||||
starts.push(current);
|
||||
}
|
||||
if let Ok(exe) = env::current_exe() {
|
||||
if let Some(parent) = exe.parent() {
|
||||
starts.push(parent.to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
for start in starts {
|
||||
if let Some(root) = detect_root_from(&start) {
|
||||
return Ok(root);
|
||||
}
|
||||
}
|
||||
|
||||
Err("Unable to locate repository root containing Journal.Sidecar.".to_string())
|
||||
}
|
||||
|
||||
fn effective_root(root_override: &Option<PathBuf>) -> Result<PathBuf, String> {
|
||||
@ -202,38 +292,59 @@ fn effective_root(root_override: &Option<PathBuf>) -> Result<PathBuf, String> {
|
||||
auto_detect_root()
|
||||
}
|
||||
|
||||
fn resolve_sidecar_path(root: &Path) -> Result<PathBuf, String> {
|
||||
let root_exe_path = root.join("Journal.Sidecar.exe");
|
||||
if root_exe_path.exists() {
|
||||
return Ok(root_exe_path);
|
||||
fn resolve_sidecar_path(root: &Path, resource_dir: Option<&Path>) -> Result<PathBuf, String> {
|
||||
#[cfg(windows)]
|
||||
let exe_name = "Journal.Sidecar.exe";
|
||||
#[cfg(not(windows))]
|
||||
let exe_name = "Journal.Sidecar";
|
||||
|
||||
if root.is_file() && root.file_name().and_then(|n| n.to_str()) == Some(exe_name) {
|
||||
return Ok(root.to_path_buf());
|
||||
}
|
||||
|
||||
let root_publish_exe_path = root.join("publish").join("Journal.Sidecar.exe");
|
||||
if root_publish_exe_path.exists() {
|
||||
return Ok(root_publish_exe_path);
|
||||
let direct = root.join(exe_name);
|
||||
if direct.exists() {
|
||||
return Ok(direct);
|
||||
}
|
||||
|
||||
let debug_path = root.join("Journal.Sidecar/bin/Debug/net10.0/Journal.Sidecar.exe");
|
||||
if debug_path.exists() {
|
||||
return Ok(debug_path);
|
||||
let tauri_bin_sidecar_path = root
|
||||
.join("Journal.App")
|
||||
.join("src-tauri")
|
||||
.join("bin")
|
||||
.join(exe_name);
|
||||
if tauri_bin_sidecar_path.exists() {
|
||||
return Ok(tauri_bin_sidecar_path);
|
||||
}
|
||||
|
||||
let release_path =
|
||||
root.join("Journal.Sidecar/bin/Release/net10.0/win-x64/publish/Journal.Sidecar.exe");
|
||||
if release_path.exists() {
|
||||
return Ok(release_path);
|
||||
}
|
||||
|
||||
let sidecar_root = root.join("Journal.Sidecar");
|
||||
if let Some(path) = find_sidecar_executable(&sidecar_root) {
|
||||
let sidecar_src_root = root.join("Journal.Sidecar");
|
||||
if let Some(path) = find_sidecar_executable(&sidecar_src_root, exe_name) {
|
||||
return Ok(path);
|
||||
}
|
||||
|
||||
Err("Journal.Sidecar.exe not found. Build Journal.Sidecar first.".to_string())
|
||||
if let Some(resource_dir) = resource_dir {
|
||||
let resource_sidecar_path = resource_dir.join("bin").join(exe_name);
|
||||
if resource_sidecar_path.exists() {
|
||||
return Ok(resource_sidecar_path);
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"{exe_name} not found in root, Journal.Sidecar tree, or resource dir for {}.",
|
||||
root.display()
|
||||
))
|
||||
}
|
||||
|
||||
fn find_sidecar_executable(search_root: &Path) -> Option<PathBuf> {
|
||||
if !search_root.exists() {
|
||||
fn parse_command_response(response_line: &str) -> Result<Value, String> {
|
||||
serde_json::from_str::<Value>(response_line)
|
||||
.map_err(|err| format!("Invalid sidecar JSON response: {err}"))
|
||||
}
|
||||
|
||||
fn read_field<'a>(data: &'a Value, camel: &str, pascal: &str) -> Option<&'a Value> {
|
||||
data.get(camel).or_else(|| data.get(pascal))
|
||||
}
|
||||
|
||||
fn find_sidecar_executable(search_root: &Path, exe_name: &str) -> Option<PathBuf> {
|
||||
if !search_root.is_dir() {
|
||||
return None;
|
||||
}
|
||||
|
||||
@ -249,6 +360,11 @@ fn find_sidecar_executable(search_root: &Path) -> Option<PathBuf> {
|
||||
};
|
||||
let path = entry.path();
|
||||
if path.is_dir() {
|
||||
if let Some(name) = path.file_name().and_then(|n| n.to_str()) {
|
||||
if name == "node_modules" || name == ".git" || name == ".vs" {
|
||||
continue;
|
||||
}
|
||||
}
|
||||
stack.push(path);
|
||||
continue;
|
||||
}
|
||||
@ -256,7 +372,7 @@ fn find_sidecar_executable(search_root: &Path) -> Option<PathBuf> {
|
||||
let is_sidecar_exe = path
|
||||
.file_name()
|
||||
.and_then(|name| name.to_str())
|
||||
.map(|name| name.eq_ignore_ascii_case("Journal.Sidecar.exe"))
|
||||
.map(|name| name.eq_ignore_ascii_case(exe_name))
|
||||
.unwrap_or(false);
|
||||
if is_sidecar_exe {
|
||||
return Some(path);
|
||||
@ -267,6 +383,37 @@ fn find_sidecar_executable(search_root: &Path) -> Option<PathBuf> {
|
||||
None
|
||||
}
|
||||
|
||||
fn resolve_gateway_appsettings_path(root: &Path) -> Result<PathBuf, String> {
|
||||
for candidate_root in candidate_roots(root) {
|
||||
let candidates = [
|
||||
candidate_root.join("webgateway").join("appsettings.json"),
|
||||
candidate_root.join("output").join("webgateway").join("appsettings.json"),
|
||||
candidate_root.join("Journal.WebGateway").join("appsettings.json"),
|
||||
];
|
||||
|
||||
for candidate in candidates {
|
||||
if candidate.exists() {
|
||||
return Ok(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Err(format!(
|
||||
"Gateway appsettings.json not found near {}.",
|
||||
root.display()
|
||||
))
|
||||
}
|
||||
|
||||
fn read_gateway_repo_root(config_path: &Path) -> Option<String> {
|
||||
let json = fs::read_to_string(config_path).ok()?;
|
||||
let value = serde_json::from_str::<Value>(&json).ok()?;
|
||||
value
|
||||
.get("GatewaySettings")
|
||||
.and_then(|section| section.get("RepoRoot"))
|
||||
.and_then(|node| node.as_str())
|
||||
.map(|value| value.to_string())
|
||||
}
|
||||
|
||||
async fn send_with_managed_sidecar(
|
||||
state: &SidecarState,
|
||||
input_line: &str,
|
||||
@ -283,7 +430,7 @@ async fn send_with_managed_sidecar(
|
||||
None => true,
|
||||
};
|
||||
if should_start {
|
||||
*guard = Some(ManagedSidecar::start(&root)?);
|
||||
*guard = Some(ManagedSidecar::start(&root, state.resource_dir.as_deref())?);
|
||||
}
|
||||
|
||||
let Some(process) = guard.as_mut() else {
|
||||
@ -304,11 +451,52 @@ async fn send_with_managed_sidecar(
|
||||
Err("Failed to send command to sidecar.".to_string())
|
||||
}
|
||||
|
||||
async fn send_sidecar_action(
|
||||
state: &SidecarState,
|
||||
action: &str,
|
||||
payload: Option<Value>,
|
||||
) -> Result<Value, String> {
|
||||
let envelope = serde_json::json!({
|
||||
"action": action,
|
||||
"payload": payload.unwrap_or_else(|| serde_json::json!({}))
|
||||
});
|
||||
let input_line = serde_json::to_string(&envelope)
|
||||
.map_err(|err| format!("Serialize command failed: {err}"))?;
|
||||
let response_line = send_with_managed_sidecar(state, &input_line).await?;
|
||||
let response = parse_command_response(&response_line)?;
|
||||
|
||||
let ok = response
|
||||
.get("ok")
|
||||
.and_then(|node| node.as_bool())
|
||||
.unwrap_or(false);
|
||||
if !ok {
|
||||
let err = response
|
||||
.get("error")
|
||||
.and_then(|node| node.as_str())
|
||||
.unwrap_or("Sidecar command failed.");
|
||||
return Err(err.to_string());
|
||||
}
|
||||
|
||||
Ok(response
|
||||
.get("data")
|
||||
.cloned()
|
||||
.unwrap_or_else(|| serde_json::json!({})))
|
||||
}
|
||||
|
||||
async fn stop_managed_sidecar(state: &SidecarState) {
|
||||
let mut guard = state.process.lock().await;
|
||||
guard.take();
|
||||
}
|
||||
|
||||
async fn stop_speech_process(state: &SidecarState) -> Result<(), String> {
|
||||
let mut guard = state.speech_process.lock().await;
|
||||
if let Some(process) = guard.take() {
|
||||
process.poll_task.abort();
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_sidecar_root(state: tauri::State<'_, SidecarState>) -> Result<Value, String> {
|
||||
let root_override = state.root_override.lock().await.clone();
|
||||
@ -319,6 +507,64 @@ async fn get_sidecar_root(state: tauri::State<'_, SidecarState>) -> Result<Value
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn get_gateway_root_status(
|
||||
state: tauri::State<'_, SidecarState>,
|
||||
) -> Result<Value, String> {
|
||||
let root_override = state.root_override.lock().await.clone();
|
||||
let root = effective_root(&root_override)?;
|
||||
let config_path = resolve_gateway_appsettings_path(&root)?;
|
||||
let configured_root = read_gateway_repo_root(&config_path);
|
||||
let authoritative_root = root.to_string_lossy().into_owned();
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"authoritativeRoot": authoritative_root,
|
||||
"gatewayConfigPath": config_path.to_string_lossy(),
|
||||
"configuredRoot": configured_root,
|
||||
"needsAdoption": configured_root.as_deref() != Some(root.to_string_lossy().as_ref())
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn adopt_sidecar_root_for_gateway(
|
||||
state: tauri::State<'_, SidecarState>,
|
||||
) -> Result<Value, String> {
|
||||
let root_override = state.root_override.lock().await.clone();
|
||||
let root = effective_root(&root_override)?;
|
||||
let config_path = resolve_gateway_appsettings_path(&root)?;
|
||||
let previous_root = read_gateway_repo_root(&config_path);
|
||||
|
||||
let json = fs::read_to_string(&config_path)
|
||||
.map_err(|err| format!("Failed to read gateway appsettings: {err}"))?;
|
||||
let mut value = serde_json::from_str::<Value>(&json)
|
||||
.map_err(|err| format!("Invalid gateway appsettings JSON: {err}"))?;
|
||||
|
||||
let root_value = Value::String(root.to_string_lossy().into_owned());
|
||||
let object = value
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "Gateway appsettings root must be a JSON object.".to_string())?;
|
||||
let gateway_settings = object
|
||||
.entry("GatewaySettings")
|
||||
.or_insert_with(|| Value::Object(serde_json::Map::new()));
|
||||
let settings_object = gateway_settings
|
||||
.as_object_mut()
|
||||
.ok_or_else(|| "GatewaySettings must be a JSON object.".to_string())?;
|
||||
settings_object.insert("RepoRoot".to_string(), root_value);
|
||||
|
||||
let updated = serde_json::to_string_pretty(&value)
|
||||
.map_err(|err| format!("Failed to serialize gateway appsettings: {err}"))?;
|
||||
fs::write(&config_path, updated)
|
||||
.map_err(|err| format!("Failed to write gateway appsettings: {err}"))?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"authoritativeRoot": root.to_string_lossy(),
|
||||
"gatewayConfigPath": config_path.to_string_lossy(),
|
||||
"previousRoot": previous_root,
|
||||
"configuredRoot": root.to_string_lossy(),
|
||||
"needsAdoption": false
|
||||
}))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn set_sidecar_root(
|
||||
state: tauri::State<'_, SidecarState>,
|
||||
@ -335,7 +581,7 @@ async fn set_sidecar_root(
|
||||
new_root.display()
|
||||
));
|
||||
}
|
||||
resolve_sidecar_path(&new_root)?;
|
||||
resolve_sidecar_path(&new_root, state.resource_dir.as_deref())?;
|
||||
(Some(new_root.clone()), new_root)
|
||||
};
|
||||
|
||||
@ -344,7 +590,6 @@ async fn set_sidecar_root(
|
||||
let mut guard = state.process.lock().await;
|
||||
guard.take();
|
||||
}
|
||||
|
||||
let is_custom = new_override.is_some();
|
||||
*state.root_override.lock().await = new_override.clone();
|
||||
|
||||
@ -363,10 +608,12 @@ async fn get_ui_settings(state: tauri::State<'_, SidecarState>) -> Result<Value,
|
||||
let settings = load_settings(&state.config_path);
|
||||
let tags = normalize_items(settings.tags, DEFAULT_SETTINGS_TAGS);
|
||||
let fragment_types = normalize_items(settings.fragment_types, DEFAULT_FRAGMENT_TYPES);
|
||||
let startup_view = normalize_startup_view(Some(settings.default_startup_view));
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"tags": tags,
|
||||
"fragmentTypes": fragment_types
|
||||
"fragmentTypes": fragment_types,
|
||||
"defaultStartupView": startup_view
|
||||
}))
|
||||
}
|
||||
|
||||
@ -375,15 +622,18 @@ async fn set_ui_settings(
|
||||
state: tauri::State<'_, SidecarState>,
|
||||
tags: Vec<String>,
|
||||
fragment_types: Vec<String>,
|
||||
default_startup_view: Option<String>,
|
||||
) -> Result<Value, String> {
|
||||
let mut settings = load_settings(&state.config_path);
|
||||
settings.tags = normalize_items(tags, DEFAULT_SETTINGS_TAGS);
|
||||
settings.fragment_types = normalize_items(fragment_types, DEFAULT_FRAGMENT_TYPES);
|
||||
settings.default_startup_view = normalize_startup_view(default_startup_view);
|
||||
save_settings(&state.config_path, &settings)?;
|
||||
|
||||
Ok(serde_json::json!({
|
||||
"tags": settings.tags,
|
||||
"fragmentTypes": settings.fragment_types
|
||||
"fragmentTypes": settings.fragment_types,
|
||||
"defaultStartupView": settings.default_startup_view
|
||||
}))
|
||||
}
|
||||
|
||||
@ -392,11 +642,153 @@ async fn shutdown(
|
||||
state: tauri::State<'_, SidecarState>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<(), String> {
|
||||
stop_speech_process(state.inner()).await?;
|
||||
let _ = send_sidecar_action(state.inner(), "speech.live.stop", None).await;
|
||||
stop_managed_sidecar(state.inner()).await;
|
||||
app_handle.exit(0);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn speech_start(
|
||||
state: tauri::State<'_, SidecarState>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Value, String> {
|
||||
let _ = app_handle.emit(
|
||||
"speech-status",
|
||||
serde_json::json!({ "state": "starting", "message": "Starting speech process..." }),
|
||||
);
|
||||
|
||||
{
|
||||
let guard = state.speech_process.lock().await;
|
||||
if let Some(existing) = guard.as_ref() {
|
||||
if existing.is_running() {
|
||||
return Ok(serde_json::json!({ "running": true }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let start_data = send_sidecar_action(state.inner(), "speech.live.start", None).await?;
|
||||
let running = read_field(&start_data, "running", "Running")
|
||||
.and_then(|node| node.as_bool())
|
||||
.unwrap_or(false);
|
||||
let status = read_field(&start_data, "status", "Status")
|
||||
.and_then(|node| node.as_str())
|
||||
.unwrap_or("starting");
|
||||
let warning = read_field(&start_data, "warning", "Warning")
|
||||
.and_then(|node| node.as_str())
|
||||
.map(|v| v.to_string());
|
||||
|
||||
let _ = app_handle.emit(
|
||||
"speech-status",
|
||||
serde_json::json!({ "state": status, "message": warning.clone().unwrap_or_else(|| status.to_string()) }),
|
||||
);
|
||||
|
||||
if !running {
|
||||
return Err(warning.unwrap_or_else(|| "Failed to start live speech.".to_string()));
|
||||
}
|
||||
|
||||
let app_for_poll = app_handle.clone();
|
||||
let poll_task = tokio::spawn(async move {
|
||||
loop {
|
||||
let state_handle = app_for_poll.state::<SidecarState>();
|
||||
let poll_data = match send_sidecar_action(
|
||||
state_handle.inner(),
|
||||
"speech.live.poll",
|
||||
Some(serde_json::json!({ "maxItems": 8 })),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(value) => value,
|
||||
Err(err) => {
|
||||
let _ = app_for_poll.emit(
|
||||
"speech-status",
|
||||
serde_json::json!({ "state": "error", "message": err }),
|
||||
);
|
||||
break;
|
||||
}
|
||||
};
|
||||
|
||||
if let Some(items) =
|
||||
read_field(&poll_data, "items", "Items").and_then(|node| node.as_array())
|
||||
{
|
||||
for item in items {
|
||||
if let Some(text) = item.as_str() {
|
||||
let _ = app_for_poll
|
||||
.emit("speech-transcript", serde_json::json!({ "text": text }));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let running = read_field(&poll_data, "running", "Running")
|
||||
.and_then(|node| node.as_bool())
|
||||
.unwrap_or(false);
|
||||
let status = read_field(&poll_data, "status", "Status")
|
||||
.and_then(|node| node.as_str())
|
||||
.unwrap_or(if running { "listening" } else { "stopped" });
|
||||
let warning = read_field(&poll_data, "warning", "Warning")
|
||||
.and_then(|node| node.as_str())
|
||||
.map(|v| v.to_string());
|
||||
if let Some(message) = warning {
|
||||
let _ = app_for_poll.emit(
|
||||
"speech-status",
|
||||
serde_json::json!({ "state": if running { "listening" } else { "error" }, "message": message }),
|
||||
);
|
||||
} else {
|
||||
let _ = app_for_poll.emit(
|
||||
"speech-status",
|
||||
serde_json::json!({ "state": status, "message": status }),
|
||||
);
|
||||
}
|
||||
|
||||
if !running {
|
||||
break;
|
||||
}
|
||||
|
||||
tokio::time::sleep(std::time::Duration::from_millis(350)).await;
|
||||
}
|
||||
});
|
||||
|
||||
let mut guard = state.speech_process.lock().await;
|
||||
*guard = Some(ManagedSpeechProcess { poll_task });
|
||||
Ok(serde_json::json!({ "running": true }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn speech_stop(
|
||||
state: tauri::State<'_, SidecarState>,
|
||||
app_handle: tauri::AppHandle,
|
||||
) -> Result<Value, String> {
|
||||
stop_speech_process(state.inner()).await?;
|
||||
let _ = send_sidecar_action(state.inner(), "speech.live.stop", None).await;
|
||||
let _ = app_handle.emit(
|
||||
"speech-status",
|
||||
serde_json::json!({ "state": "stopped", "message": "Dictation stopped." }),
|
||||
);
|
||||
Ok(serde_json::json!({ "running": false }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn speech_cleanup_probe(path: String) -> Result<Value, String> {
|
||||
if path.trim().is_empty() {
|
||||
return Ok(serde_json::json!({ "deleted": false }));
|
||||
}
|
||||
|
||||
let target = PathBuf::from(path);
|
||||
let normalized = target.to_string_lossy().to_lowercase();
|
||||
if !normalized.contains("tauri-plugin-mic-recorder") || !normalized.ends_with(".wav") {
|
||||
return Ok(serde_json::json!({ "deleted": false }));
|
||||
}
|
||||
|
||||
if !target.exists() {
|
||||
return Ok(serde_json::json!({ "deleted": false }));
|
||||
}
|
||||
|
||||
fs::remove_file(&target).map_err(|err| format!("Failed to remove probe recording: {err}"))?;
|
||||
|
||||
Ok(serde_json::json!({ "deleted": true }))
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
async fn sidecar_command(
|
||||
state: tauri::State<'_, SidecarState>,
|
||||
@ -409,18 +801,24 @@ async fn sidecar_command(
|
||||
let input_line = serde_json::to_string(&command)
|
||||
.map_err(|err| format!("Serialize command failed: {err}"))?;
|
||||
let response_line = send_with_managed_sidecar(state.inner(), &input_line).await?;
|
||||
serde_json::from_str::<Value>(&response_line)
|
||||
.map_err(|err| format!("Invalid sidecar JSON response: {err}"))
|
||||
parse_command_response(&response_line)
|
||||
}
|
||||
|
||||
#[cfg_attr(mobile, tauri::mobile_entry_point)]
|
||||
pub fn run() {
|
||||
let app = tauri::Builder::default()
|
||||
.plugin(tauri_plugin_mic_recorder::init())
|
||||
.plugin(tauri_plugin_dialog::init())
|
||||
.plugin(tauri_plugin_opener::init())
|
||||
.invoke_handler(tauri::generate_handler![
|
||||
sidecar_command,
|
||||
shutdown,
|
||||
speech_start,
|
||||
speech_stop,
|
||||
speech_cleanup_probe,
|
||||
get_sidecar_root,
|
||||
get_gateway_root_status,
|
||||
adopt_sidecar_root_for_gateway,
|
||||
set_sidecar_root,
|
||||
get_ui_settings,
|
||||
set_ui_settings,
|
||||
@ -430,12 +828,14 @@ pub fn run() {
|
||||
fs::create_dir_all(&config_dir).ok();
|
||||
let config_path = config_dir.join("settings.json");
|
||||
let settings = load_settings(&config_path);
|
||||
let root_override = settings.sidecar_root.map(PathBuf::from);
|
||||
let root_override = settings.sidecar_root.as_ref().map(PathBuf::from);
|
||||
|
||||
app.manage(SidecarState {
|
||||
process: Mutex::new(None),
|
||||
speech_process: Mutex::new(None),
|
||||
root_override: Mutex::new(root_override),
|
||||
config_path,
|
||||
resource_dir: app.path().resource_dir().ok(),
|
||||
});
|
||||
Ok(())
|
||||
})
|
||||
@ -448,6 +848,11 @@ pub fn run() {
|
||||
if let Ok(mut guard) = state.process.try_lock() {
|
||||
guard.take();
|
||||
};
|
||||
if let Ok(mut guard) = state.speech_process.try_lock() {
|
||||
if let Some(speech) = guard.take() {
|
||||
speech.poll_task.abort();
|
||||
}
|
||||
};
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@ -2,11 +2,11 @@
|
||||
"$schema": "https://schema.tauri.app/config/2",
|
||||
"productName": "Project Journal",
|
||||
"version": "0.1.0",
|
||||
"identifier": "com.stan.journal",
|
||||
"identifier": "com.idsolutions.journal",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
"devUrl": "http://localhost:1420",
|
||||
"beforeBuildCommand": "npm run build",
|
||||
"beforeBuildCommand": "npm run tauri:prebuild && npm run build",
|
||||
"frontendDist": "../build"
|
||||
},
|
||||
"app": {
|
||||
@ -24,6 +24,7 @@
|
||||
"bundle": {
|
||||
"active": true,
|
||||
"targets": "all",
|
||||
"resources": ["bin"],
|
||||
"icon": [
|
||||
"icons/32x32.png",
|
||||
"icons/128x128.png",
|
||||
@ -32,4 +33,4 @@
|
||||
"icons/icon.ico"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -2,13 +2,20 @@
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.png" />
|
||||
<link rel="stylesheet" href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" />
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<link rel="icon" href="%sveltekit.assets%/icon.ico" />
|
||||
<link
|
||||
rel="stylesheet"
|
||||
href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200"
|
||||
/>
|
||||
<link rel="stylesheet" href="style.css?v=20260328b" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover"
|
||||
/>
|
||||
<title>Journal</title>
|
||||
%sveltekit.head%
|
||||
</head>
|
||||
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
222
Journal.App/src/lib/backend/ai.ts
Normal file
@ -0,0 +1,222 @@
|
||||
import { sendCommand } from "./client";
|
||||
import { pickCase } from "./normalize";
|
||||
|
||||
//#region 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;
|
||||
};
|
||||
//#endregion
|
||||
|
||||
//#region PascalCase Normalizers
|
||||
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;
|
||||
};
|
||||
//#endregion
|
||||
|
||||
//#region 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),
|
||||
};
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region 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);
|
||||
}
|
||||
//#endregion
|
||||
@ -1,22 +1,18 @@
|
||||
import { sendCommand } from "./client";
|
||||
import { pickCase } from "./normalize";
|
||||
|
||||
export function hydrateWorkspace(password: string): Promise<unknown> {
|
||||
return sendCommand<unknown>({
|
||||
action: "db.hydrate_workspace",
|
||||
payload: { password }
|
||||
payload: { password },
|
||||
});
|
||||
}
|
||||
|
||||
type RuntimeConfigRaw = {
|
||||
dataDirectory?: string;
|
||||
vaultDirectory?: string;
|
||||
DataDirectory?: string;
|
||||
VaultDirectory?: string;
|
||||
};
|
||||
|
||||
type RuntimeConfig = {
|
||||
dataDirectory: string;
|
||||
vaultDirectory: string;
|
||||
};
|
||||
|
||||
@ -24,17 +20,18 @@ type PersistOptions = {
|
||||
keepalive?: boolean;
|
||||
};
|
||||
|
||||
async function getRuntimeConfig(options: PersistOptions = {}): Promise<RuntimeConfig> {
|
||||
async function getRuntimeConfig(
|
||||
options: PersistOptions = {},
|
||||
): Promise<RuntimeConfig> {
|
||||
const data = await sendCommand<RuntimeConfigRaw>(
|
||||
{
|
||||
action: "config.get"
|
||||
action: "config.get",
|
||||
},
|
||||
options
|
||||
options,
|
||||
);
|
||||
|
||||
return {
|
||||
dataDirectory: pickCase(data, "dataDirectory", "DataDirectory", ""),
|
||||
vaultDirectory: pickCase(data, "vaultDirectory", "VaultDirectory", "")
|
||||
vaultDirectory: pickCase(data, "vaultDirectory", "VaultDirectory", ""),
|
||||
};
|
||||
}
|
||||
|
||||
@ -45,8 +42,7 @@ export async function unlockVaultWorkspace(password: string): Promise<void> {
|
||||
payload: {
|
||||
password,
|
||||
vaultDirectory: config.vaultDirectory,
|
||||
dataDirectory: config.dataDirectory
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
if (!loaded) {
|
||||
@ -57,12 +53,14 @@ export async function unlockVaultWorkspace(password: string): Promise<void> {
|
||||
action: "db.hydrate_workspace",
|
||||
payload: {
|
||||
password,
|
||||
dataDirectory: config.dataDirectory
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function persistAndClearVault(password: string, options: PersistOptions = {}): Promise<void> {
|
||||
export async function persistAndClearVault(
|
||||
password: string,
|
||||
options: PersistOptions = {},
|
||||
): Promise<void> {
|
||||
const config = await getRuntimeConfig(options);
|
||||
|
||||
await sendCommand<boolean>(
|
||||
@ -71,19 +69,16 @@ export async function persistAndClearVault(password: string, options: PersistOpt
|
||||
payload: {
|
||||
password,
|
||||
vaultDirectory: config.vaultDirectory,
|
||||
dataDirectory: config.dataDirectory
|
||||
}
|
||||
},
|
||||
},
|
||||
options
|
||||
options,
|
||||
);
|
||||
|
||||
await sendCommand<boolean>(
|
||||
{
|
||||
action: "vault.clear_data_directory",
|
||||
payload: {
|
||||
dataDirectory: config.dataDirectory
|
||||
}
|
||||
payload: {},
|
||||
},
|
||||
options
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
import { invoke } from "$lib/runtime/invoke";
|
||||
import {
|
||||
clearVaultSession,
|
||||
requestVaultUnlock,
|
||||
} from "$lib/stores/session";
|
||||
import type { BackendCommand, BackendResponse } from "./types";
|
||||
|
||||
function newCorrelationId(): string {
|
||||
@ -7,20 +11,43 @@ function newCorrelationId(): string {
|
||||
|
||||
type SendCommandOptions = {
|
||||
keepalive?: boolean;
|
||||
unlockRetryAttempted?: boolean;
|
||||
};
|
||||
|
||||
export async function sendCommand<T>(command: BackendCommand, options: SendCommandOptions = {}): Promise<T> {
|
||||
function isDatabaseLockedError(message: string): boolean {
|
||||
return message.toLowerCase().includes("database is locked");
|
||||
}
|
||||
|
||||
export async function sendCommand<T>(
|
||||
command: BackendCommand,
|
||||
options: SendCommandOptions = {},
|
||||
): Promise<T> {
|
||||
const envelope: BackendCommand = {
|
||||
...command,
|
||||
correlationId: command.correlationId ?? newCorrelationId()
|
||||
correlationId: command.correlationId ?? newCorrelationId(),
|
||||
};
|
||||
const response = await invoke<BackendResponse<T>>("sidecar_command", {
|
||||
command: envelope,
|
||||
keepalive: options.keepalive === true
|
||||
keepalive: options.keepalive === true,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(response.error || "Backend command failed");
|
||||
const errorMessage = response.error || "Backend command failed";
|
||||
if (
|
||||
!options.unlockRetryAttempted &&
|
||||
isDatabaseLockedError(errorMessage)
|
||||
) {
|
||||
clearVaultSession();
|
||||
const unlocked = await requestVaultUnlock();
|
||||
if (unlocked) {
|
||||
return sendCommand<T>(command, {
|
||||
...options,
|
||||
unlockRetryAttempted: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(errorMessage);
|
||||
}
|
||||
|
||||
return response.data;
|
||||
|
||||
195
Journal.App/src/lib/backend/conversations.ts
Normal file
@ -0,0 +1,195 @@
|
||||
import { sendCommand } from "./client";
|
||||
import { pickCase } from "./normalize";
|
||||
|
||||
//#region 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;
|
||||
};
|
||||
//#endregion
|
||||
|
||||
//#region PascalCase Normalizers
|
||||
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;
|
||||
};
|
||||
//#endregion
|
||||
|
||||
//#region 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),
|
||||
};
|
||||
}
|
||||
//#endregion
|
||||
|
||||
//#region 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);
|
||||
}
|
||||
//#endregion
|
||||
@ -1,5 +1,9 @@
|
||||
import { sendCommand } from "./client";
|
||||
import { normalizeFragment, type FragmentDto, type FragmentDtoRaw } from "./fragments";
|
||||
import {
|
||||
normalizeFragment,
|
||||
type FragmentDto,
|
||||
type FragmentDtoRaw,
|
||||
} from "./fragments";
|
||||
import { pickCase } from "./normalize";
|
||||
|
||||
export type ParsedSectionDto = {
|
||||
@ -31,7 +35,6 @@ export type EntrySaveResultDto = {
|
||||
};
|
||||
|
||||
export type EntrySearchRequestDto = {
|
||||
dataDirectory: string;
|
||||
query?: string;
|
||||
section?: string;
|
||||
startDate?: string;
|
||||
@ -107,80 +110,124 @@ function normalizeSection(raw: ParsedSectionDtoRaw): ParsedSectionDto {
|
||||
return {
|
||||
title: pickCase(raw, "title", "Title", ""),
|
||||
content: pickCase(raw, "content", "Content", [] as string[]),
|
||||
checkboxes: pickCase(raw, "checkboxes", "Checkboxes", {} as Record<string, boolean>)
|
||||
checkboxes: pickCase(
|
||||
raw,
|
||||
"checkboxes",
|
||||
"Checkboxes",
|
||||
{} as Record<string, boolean>,
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeJournalEntry(raw: JournalEntryDtoRaw | undefined): JournalEntryDto {
|
||||
const fragments = pickCase(raw, "fragments", "Fragments", [] as FragmentDtoRaw[]);
|
||||
const sections = pickCase(raw, "sections", "Sections", {} as Record<string, ParsedSectionDtoRaw>);
|
||||
function normalizeJournalEntry(
|
||||
raw: JournalEntryDtoRaw | undefined,
|
||||
): JournalEntryDto {
|
||||
const fragments = pickCase(
|
||||
raw,
|
||||
"fragments",
|
||||
"Fragments",
|
||||
[] as FragmentDtoRaw[],
|
||||
);
|
||||
const sections = pickCase(
|
||||
raw,
|
||||
"sections",
|
||||
"Sections",
|
||||
{} as Record<string, ParsedSectionDtoRaw>,
|
||||
);
|
||||
return {
|
||||
date: pickCase(raw, "date", "Date", ""),
|
||||
fragments: fragments.map(normalizeFragment),
|
||||
rawContent: pickCase(raw, "rawContent", "RawContent", ""),
|
||||
sections: Object.fromEntries(
|
||||
Object.entries(sections).map(([key, value]) => [key, normalizeSection(value)])
|
||||
)
|
||||
Object.entries(sections).map(([key, value]) => [
|
||||
key,
|
||||
normalizeSection(value),
|
||||
]),
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEntryListItem(raw: EntryListItemDtoRaw): EntryListItemDto {
|
||||
return {
|
||||
fileName: pickCase(raw, "fileName", "FileName", ""),
|
||||
filePath: pickCase(raw, "filePath", "FilePath", "")
|
||||
filePath: pickCase(raw, "filePath", "FilePath", ""),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEntryLoadResult(raw: EntryLoadResultDtoRaw): EntryLoadResultDto {
|
||||
const nestedEntry = pickCase(raw, "entry", "Entry", undefined as JournalEntryDtoRaw | undefined);
|
||||
const entry =
|
||||
nestedEntry
|
||||
? normalizeJournalEntry(nestedEntry)
|
||||
: normalizeJournalEntry({
|
||||
date: pickCase(raw, "date", "Date", undefined as string | undefined),
|
||||
rawContent: pickCase(raw, "rawContent", "RawContent", undefined as string | undefined),
|
||||
fragments: [],
|
||||
sections: {}
|
||||
});
|
||||
function normalizeEntryLoadResult(
|
||||
raw: EntryLoadResultDtoRaw,
|
||||
): EntryLoadResultDto {
|
||||
const nestedEntry = pickCase(
|
||||
raw,
|
||||
"entry",
|
||||
"Entry",
|
||||
undefined as JournalEntryDtoRaw | undefined,
|
||||
);
|
||||
const entry = nestedEntry
|
||||
? normalizeJournalEntry(nestedEntry)
|
||||
: normalizeJournalEntry({
|
||||
date: pickCase(raw, "date", "Date", undefined as string | undefined),
|
||||
rawContent: pickCase(
|
||||
raw,
|
||||
"rawContent",
|
||||
"RawContent",
|
||||
undefined as string | undefined,
|
||||
),
|
||||
fragments: [],
|
||||
sections: {},
|
||||
});
|
||||
|
||||
return {
|
||||
fileName: pickCase(raw, "fileName", "FileName", ""),
|
||||
filePath: pickCase(raw, "filePath", "FilePath", ""),
|
||||
entry
|
||||
entry,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeEntrySearchResult(raw: EntrySearchResultDtoRaw): EntrySearchResultDto {
|
||||
const nestedEntry = pickCase(raw, "entry", "Entry", undefined as JournalEntryDtoRaw | undefined);
|
||||
const entry =
|
||||
nestedEntry
|
||||
? normalizeJournalEntry(nestedEntry)
|
||||
: normalizeJournalEntry({
|
||||
date: pickCase(raw, "date", "Date", undefined as string | undefined),
|
||||
rawContent: pickCase(raw, "rawContent", "RawContent", undefined as string | undefined),
|
||||
fragments: [],
|
||||
sections: {}
|
||||
});
|
||||
function normalizeEntrySearchResult(
|
||||
raw: EntrySearchResultDtoRaw,
|
||||
): EntrySearchResultDto {
|
||||
const nestedEntry = pickCase(
|
||||
raw,
|
||||
"entry",
|
||||
"Entry",
|
||||
undefined as JournalEntryDtoRaw | undefined,
|
||||
);
|
||||
const entry = nestedEntry
|
||||
? normalizeJournalEntry(nestedEntry)
|
||||
: normalizeJournalEntry({
|
||||
date: pickCase(raw, "date", "Date", undefined as string | undefined),
|
||||
rawContent: pickCase(
|
||||
raw,
|
||||
"rawContent",
|
||||
"RawContent",
|
||||
undefined as string | undefined,
|
||||
),
|
||||
fragments: [],
|
||||
sections: {},
|
||||
});
|
||||
|
||||
return {
|
||||
fileName: pickCase(raw, "fileName", "FileName", ""),
|
||||
entry
|
||||
entry,
|
||||
};
|
||||
}
|
||||
|
||||
export async function listEntries(dataDirectory?: string): Promise<EntryListItemDto[]> {
|
||||
export async function listEntries(): Promise<EntryListItemDto[]> {
|
||||
const data = await sendCommand<EntryListItemDtoRaw[]>({
|
||||
action: "entries.list",
|
||||
payload: { dataDirectory }
|
||||
payload: {},
|
||||
});
|
||||
|
||||
return data.map(normalizeEntryListItem).filter((item) => Boolean(item.filePath));
|
||||
return data
|
||||
.map(normalizeEntryListItem)
|
||||
.filter((item) => Boolean(item.filePath));
|
||||
}
|
||||
|
||||
export async function loadEntry(filePath: string): Promise<EntryLoadResultDto> {
|
||||
const data = await sendCommand<EntryLoadResultDtoRaw>({
|
||||
action: "entries.load",
|
||||
payload: { filePath }
|
||||
payload: { filePath },
|
||||
});
|
||||
|
||||
return normalizeEntryLoadResult(data);
|
||||
@ -194,26 +241,30 @@ export async function saveEntry(payload: {
|
||||
}): Promise<EntrySaveResultDto> {
|
||||
const data = await sendCommand<EntrySaveResultDtoRaw>({
|
||||
action: "entries.save",
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
|
||||
return {
|
||||
filePath: pickCase(data, "filePath", "FilePath", "")
|
||||
filePath: pickCase(data, "filePath", "FilePath", ""),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteEntry(filePath: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "entries.delete",
|
||||
payload: { filePath }
|
||||
payload: { filePath },
|
||||
});
|
||||
}
|
||||
|
||||
export async function searchEntries(payload: EntrySearchRequestDto): Promise<EntrySearchResultDto[]> {
|
||||
export async function searchEntries(
|
||||
payload: EntrySearchRequestDto,
|
||||
): Promise<EntrySearchResultDto[]> {
|
||||
const data = await sendCommand<EntrySearchResultDtoRaw[]>({
|
||||
action: "search.entries",
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
|
||||
return data.map(normalizeEntrySearchResult).filter((item) => Boolean(item.fileName));
|
||||
return data
|
||||
.map(normalizeEntrySearchResult)
|
||||
.filter((item) => Boolean(item.fileName));
|
||||
}
|
||||
|
||||
@ -41,13 +41,13 @@ export function normalizeFragment(raw: FragmentDtoRaw): FragmentDto {
|
||||
type: pickCase(raw, "type", "Type", ""),
|
||||
description: pickCase(raw, "description", "Description", ""),
|
||||
time: pickCase(raw, "time", "Time", ""),
|
||||
tags: pickCase(raw, "tags", "Tags", [] as string[])
|
||||
tags: pickCase(raw, "tags", "Tags", [] as string[]),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listFragments(): Promise<FragmentDto[]> {
|
||||
const data = await sendCommand<FragmentDtoRaw[]>({
|
||||
action: "fragments.list"
|
||||
action: "fragments.list",
|
||||
});
|
||||
return data.map(normalizeFragment).filter((item) => Boolean(item.id));
|
||||
}
|
||||
@ -55,32 +55,37 @@ export async function listFragments(): Promise<FragmentDto[]> {
|
||||
export async function getFragment(id: string): Promise<FragmentDto | null> {
|
||||
const data = await sendCommand<FragmentDtoRaw | null>({
|
||||
action: "fragments.get",
|
||||
id
|
||||
id,
|
||||
});
|
||||
if (!data) return null;
|
||||
const normalized = normalizeFragment(data);
|
||||
return normalized.id ? normalized : null;
|
||||
}
|
||||
|
||||
export async function createFragment(payload: CreateFragmentPayload): Promise<FragmentDto> {
|
||||
export async function createFragment(
|
||||
payload: CreateFragmentPayload,
|
||||
): Promise<FragmentDto> {
|
||||
const data = await sendCommand<FragmentDtoRaw>({
|
||||
action: "fragments.create",
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
return normalizeFragment(data);
|
||||
}
|
||||
|
||||
export function updateFragment(id: string, payload: UpdateFragmentPayload): Promise<boolean> {
|
||||
export function updateFragment(
|
||||
id: string,
|
||||
payload: UpdateFragmentPayload,
|
||||
): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "fragments.update",
|
||||
id,
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteFragment(id: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "fragments.delete",
|
||||
id
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
@ -38,13 +38,13 @@ export function normalizeList(raw: ListDocumentDtoRaw): ListDocumentDto {
|
||||
label: pickCase(raw, "label", "Label", ""),
|
||||
content: pickCase(raw, "content", "Content", ""),
|
||||
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
|
||||
updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", "")
|
||||
updatedAt: pickCase(raw, "updatedAt", "UpdatedAt", ""),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listLists(): Promise<ListDocumentDto[]> {
|
||||
const data = await sendCommand<ListDocumentDtoRaw[]>({
|
||||
action: "lists.list"
|
||||
action: "lists.list",
|
||||
});
|
||||
return data.map(normalizeList).filter((item) => Boolean(item.id));
|
||||
}
|
||||
@ -52,32 +52,37 @@ export async function listLists(): Promise<ListDocumentDto[]> {
|
||||
export async function getList(id: string): Promise<ListDocumentDto | null> {
|
||||
const data = await sendCommand<ListDocumentDtoRaw | null>({
|
||||
action: "lists.get",
|
||||
id
|
||||
id,
|
||||
});
|
||||
if (!data) return null;
|
||||
const normalized = normalizeList(data);
|
||||
return normalized.id ? normalized : null;
|
||||
}
|
||||
|
||||
export async function createList(payload: CreateListPayload): Promise<ListDocumentDto> {
|
||||
export async function createList(
|
||||
payload: CreateListPayload,
|
||||
): Promise<ListDocumentDto> {
|
||||
const data = await sendCommand<ListDocumentDtoRaw>({
|
||||
action: "lists.create",
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
return normalizeList(data);
|
||||
}
|
||||
|
||||
export function updateList(id: string, payload: UpdateListPayload): Promise<boolean> {
|
||||
export function updateList(
|
||||
id: string,
|
||||
payload: UpdateListPayload,
|
||||
): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "lists.update",
|
||||
id,
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteList(id: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "lists.delete",
|
||||
id
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
@ -1,14 +1,16 @@
|
||||
type UnknownObject = Record<string, unknown>;
|
||||
|
||||
function asObject(value: unknown): UnknownObject | undefined {
|
||||
return value && typeof value === "object" ? (value as UnknownObject) : undefined;
|
||||
return value && typeof value === "object"
|
||||
? (value as UnknownObject)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
export function pickCase<T>(
|
||||
source: unknown,
|
||||
camelKey: string,
|
||||
pascalKey: string,
|
||||
fallback: T
|
||||
fallback: T,
|
||||
): T {
|
||||
const obj = asObject(source);
|
||||
if (!obj) return fallback;
|
||||
|
||||
33
Journal.App/src/lib/backend/speech.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import { invoke } from "$lib/runtime/invoke";
|
||||
import {
|
||||
startRecording as startMicRecording,
|
||||
stopRecording as stopMicRecording,
|
||||
} from "tauri-plugin-mic-recorder-api";
|
||||
|
||||
type SpeechControlResult = {
|
||||
running: boolean;
|
||||
pid?: number;
|
||||
launch?: string;
|
||||
};
|
||||
|
||||
export async function startSpeechDictation(): Promise<SpeechControlResult> {
|
||||
return invoke<SpeechControlResult>("speech_start");
|
||||
}
|
||||
|
||||
export async function stopSpeechDictation(): Promise<SpeechControlResult> {
|
||||
return invoke<SpeechControlResult>("speech_stop");
|
||||
}
|
||||
|
||||
export async function probeMicrophoneAccess(): Promise<string> {
|
||||
await startMicRecording();
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
const outputPath = await stopMicRecording();
|
||||
try {
|
||||
await invoke<{ deleted: boolean }>("speech_cleanup_probe", {
|
||||
path: outputPath,
|
||||
});
|
||||
} catch {
|
||||
// Keep probe non-blocking; cleanup failure should not break dictation start.
|
||||
}
|
||||
return outputPath;
|
||||
}
|
||||
@ -37,32 +37,38 @@ type EntryTemplateSaveResultDtoRaw = {
|
||||
FilePath?: string;
|
||||
};
|
||||
|
||||
function normalizeTemplateItem(raw: EntryTemplateItemDtoRaw): EntryTemplateItemDto {
|
||||
function normalizeTemplateItem(
|
||||
raw: EntryTemplateItemDtoRaw,
|
||||
): EntryTemplateItemDto {
|
||||
return {
|
||||
fileName: pickCase(raw, "fileName", "FileName", ""),
|
||||
filePath: pickCase(raw, "filePath", "FilePath", "")
|
||||
filePath: pickCase(raw, "filePath", "FilePath", ""),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listEntryTemplates(dataDirectory?: string): Promise<EntryTemplateItemDto[]> {
|
||||
export async function listEntryTemplates(): Promise<EntryTemplateItemDto[]> {
|
||||
const data = await sendCommand<EntryTemplateItemDtoRaw[]>({
|
||||
action: "templates.list",
|
||||
payload: { dataDirectory }
|
||||
payload: {},
|
||||
});
|
||||
|
||||
return data.map(normalizeTemplateItem).filter((item) => Boolean(item.filePath));
|
||||
return data
|
||||
.map(normalizeTemplateItem)
|
||||
.filter((item) => Boolean(item.filePath));
|
||||
}
|
||||
|
||||
export async function loadEntryTemplate(filePath: string): Promise<EntryTemplateLoadResultDto> {
|
||||
export async function loadEntryTemplate(
|
||||
filePath: string,
|
||||
): Promise<EntryTemplateLoadResultDto> {
|
||||
const data = await sendCommand<EntryTemplateLoadResultDtoRaw>({
|
||||
action: "templates.load",
|
||||
payload: { filePath }
|
||||
payload: { filePath },
|
||||
});
|
||||
|
||||
return {
|
||||
fileName: pickCase(data, "fileName", "FileName", ""),
|
||||
filePath: pickCase(data, "filePath", "FilePath", ""),
|
||||
content: pickCase(data, "content", "Content", "")
|
||||
content: pickCase(data, "content", "Content", ""),
|
||||
};
|
||||
}
|
||||
|
||||
@ -70,21 +76,20 @@ export async function saveEntryTemplate(payload: {
|
||||
name: string;
|
||||
content: string;
|
||||
filePath?: string;
|
||||
dataDirectory?: string;
|
||||
}): Promise<EntryTemplateSaveResultDto> {
|
||||
const data = await sendCommand<EntryTemplateSaveResultDtoRaw>({
|
||||
action: "templates.save",
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
|
||||
return {
|
||||
filePath: pickCase(data, "filePath", "FilePath", "")
|
||||
filePath: pickCase(data, "filePath", "FilePath", ""),
|
||||
};
|
||||
}
|
||||
|
||||
export async function deleteEntryTemplate(filePath: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "templates.delete",
|
||||
payload: { filePath }
|
||||
payload: { filePath },
|
||||
});
|
||||
}
|
||||
|
||||
@ -66,7 +66,7 @@ function normalizeItem(raw: TodoItemDtoRaw): TodoItemDto {
|
||||
listId: pickCase(raw, "listId", "ListId", ""),
|
||||
text: pickCase(raw, "text", "Text", ""),
|
||||
done: pickCase(raw, "done", "Done", false),
|
||||
sortOrder: pickCase(raw, "sortOrder", "SortOrder", 0)
|
||||
sortOrder: pickCase(raw, "sortOrder", "SortOrder", 0),
|
||||
};
|
||||
}
|
||||
|
||||
@ -76,13 +76,13 @@ function normalizeList(raw: TodoListDtoRaw): TodoListDto {
|
||||
id: pickCase(raw, "id", "Id", ""),
|
||||
label: pickCase(raw, "label", "Label", ""),
|
||||
createdAt: pickCase(raw, "createdAt", "CreatedAt", ""),
|
||||
items: rawItems.map(normalizeItem)
|
||||
items: rawItems.map(normalizeItem),
|
||||
};
|
||||
}
|
||||
|
||||
export async function listTodoLists(): Promise<TodoListDto[]> {
|
||||
const data = await sendCommand<TodoListDtoRaw[]>({
|
||||
action: "todos.list"
|
||||
action: "todos.list",
|
||||
});
|
||||
return data.map(normalizeList).filter((item) => Boolean(item.id));
|
||||
}
|
||||
@ -90,55 +90,65 @@ export async function listTodoLists(): Promise<TodoListDto[]> {
|
||||
export async function getTodoList(id: string): Promise<TodoListDto | null> {
|
||||
const data = await sendCommand<TodoListDtoRaw | null>({
|
||||
action: "todos.get",
|
||||
id
|
||||
id,
|
||||
});
|
||||
if (!data) return null;
|
||||
const normalized = normalizeList(data);
|
||||
return normalized.id ? normalized : null;
|
||||
}
|
||||
|
||||
export async function createTodoList(payload: CreateTodoListPayload): Promise<TodoListDto> {
|
||||
export async function createTodoList(
|
||||
payload: CreateTodoListPayload,
|
||||
): Promise<TodoListDto> {
|
||||
const data = await sendCommand<TodoListDtoRaw>({
|
||||
action: "todos.create",
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
return normalizeList(data);
|
||||
}
|
||||
|
||||
export function updateTodoList(id: string, payload: UpdateTodoListPayload): Promise<boolean> {
|
||||
export function updateTodoList(
|
||||
id: string,
|
||||
payload: UpdateTodoListPayload,
|
||||
): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "todos.update",
|
||||
id,
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTodoList(id: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "todos.delete",
|
||||
id
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
export async function createTodoItem(payload: CreateTodoItemPayload): Promise<TodoItemDto> {
|
||||
export async function createTodoItem(
|
||||
payload: CreateTodoItemPayload,
|
||||
): Promise<TodoItemDto> {
|
||||
const data = await sendCommand<TodoItemDtoRaw>({
|
||||
action: "todos.items.create",
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
return normalizeItem(data);
|
||||
}
|
||||
|
||||
export function updateTodoItem(id: string, payload: UpdateTodoItemPayload): Promise<boolean> {
|
||||
export function updateTodoItem(
|
||||
id: string,
|
||||
payload: UpdateTodoItemPayload,
|
||||
): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "todos.items.update",
|
||||
id,
|
||||
payload
|
||||
payload,
|
||||
});
|
||||
}
|
||||
|
||||
export function deleteTodoItem(id: string): Promise<boolean> {
|
||||
return sendCommand<boolean>({
|
||||
action: "todos.items.delete",
|
||||
id
|
||||
id,
|
||||
});
|
||||
}
|
||||
|
||||
@ -10,4 +10,3 @@ export type BackendCommand = {
|
||||
export type BackendOk<T> = { ok: true; data: T };
|
||||
export type BackendErr = { ok: false; error: string };
|
||||
export type BackendResponse<T> = BackendOk<T> | BackendErr;
|
||||
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
|
||||
@ -67,9 +68,15 @@
|
||||
|
||||
<div class="modal-actions">
|
||||
{#if showCancel}
|
||||
<button type="button" class="secondary" on:click={handleCancel}>{cancelText}</button>
|
||||
<button type="button" class="secondary" on:click={handleCancel}
|
||||
>{cancelText}</button
|
||||
>
|
||||
{/if}
|
||||
<button type="button" class:danger={tone === "danger"} on:click={handleConfirm}>
|
||||
<button
|
||||
type="button"
|
||||
class:danger={tone === "danger"}
|
||||
on:click={handleConfirm}
|
||||
>
|
||||
{confirmText}
|
||||
</button>
|
||||
</div>
|
||||
@ -155,4 +162,29 @@
|
||||
background: var(--surface-3);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.modal-backdrop {
|
||||
align-items: end;
|
||||
padding: 12px 12px calc(12px + env(safe-area-inset-bottom, 0px));
|
||||
}
|
||||
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: none;
|
||||
border-radius: 16px;
|
||||
padding: 16px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.modal-actions {
|
||||
flex-direction: column-reverse;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.modal-actions button {
|
||||
width: 100%;
|
||||
min-height: 42px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,12 +1,31 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
export let onVisibleMonthChange: (month: { year: number; month: number; label: string }) => void = () => {};
|
||||
export let onSelectedDateChange: (payload: { year: number; month: number; day: number; key: string }) => void =
|
||||
() => {};
|
||||
export let onVisibleMonthChange: (month: {
|
||||
year: number;
|
||||
month: number;
|
||||
label: string;
|
||||
}) => void = () => {};
|
||||
export let onSelectedDateChange: (payload: {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
key: string;
|
||||
}) => void = () => {};
|
||||
export let onDateActivate: (payload: {
|
||||
year: number;
|
||||
month: number;
|
||||
day: number;
|
||||
key: string;
|
||||
}) => void = () => {};
|
||||
|
||||
const today = new Date();
|
||||
let currentYear = today.getFullYear();
|
||||
let currentMonth = today.getMonth();
|
||||
let selectedDateKey = getDateKey(today.getFullYear(), today.getMonth(), today.getDate());
|
||||
let selectedDateKey = getDateKey(
|
||||
today.getFullYear(),
|
||||
today.getMonth(),
|
||||
today.getDate(),
|
||||
);
|
||||
|
||||
const weekdays = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
||||
|
||||
@ -39,7 +58,14 @@
|
||||
if (!cell.inMonth) {
|
||||
setViewDate(cell.year, cell.month);
|
||||
}
|
||||
selectedDateKey = getDateKey(cell.year, cell.month, cell.day);
|
||||
const key = getDateKey(cell.year, cell.month, cell.day);
|
||||
selectedDateKey = key;
|
||||
onDateActivate({
|
||||
year: cell.year,
|
||||
month: cell.month,
|
||||
day: cell.day,
|
||||
key,
|
||||
});
|
||||
}
|
||||
|
||||
function getCalendarCells(year: number, month: number): CalendarCell[] {
|
||||
@ -54,14 +80,20 @@
|
||||
for (let i = 0; i < startOffset; i += 1) {
|
||||
const day = prevMonthLastDay - startOffset + i + 1;
|
||||
const prevMonthDate = new Date(year, month - 1, day);
|
||||
const key = getDateKey(prevMonthDate.getFullYear(), prevMonthDate.getMonth(), day);
|
||||
const key = getDateKey(
|
||||
prevMonthDate.getFullYear(),
|
||||
prevMonthDate.getMonth(),
|
||||
day,
|
||||
);
|
||||
nextCells.push({
|
||||
day,
|
||||
month: prevMonthDate.getMonth(),
|
||||
year: prevMonthDate.getFullYear(),
|
||||
inMonth: false,
|
||||
isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
isSelected: key === selectedDateKey
|
||||
isToday:
|
||||
key ===
|
||||
getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
isSelected: key === selectedDateKey,
|
||||
});
|
||||
}
|
||||
|
||||
@ -72,43 +104,73 @@
|
||||
month,
|
||||
year,
|
||||
inMonth: true,
|
||||
isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
isSelected: key === selectedDateKey
|
||||
isToday:
|
||||
key ===
|
||||
getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
isSelected: key === selectedDateKey,
|
||||
});
|
||||
}
|
||||
|
||||
const trailing = (7 - (nextCells.length % 7)) % 7;
|
||||
for (let day = 1; day <= trailing; day += 1) {
|
||||
const nextMonthDate = new Date(year, month + 1, day);
|
||||
const key = getDateKey(nextMonthDate.getFullYear(), nextMonthDate.getMonth(), day);
|
||||
const key = getDateKey(
|
||||
nextMonthDate.getFullYear(),
|
||||
nextMonthDate.getMonth(),
|
||||
day,
|
||||
);
|
||||
nextCells.push({
|
||||
day,
|
||||
month: nextMonthDate.getMonth(),
|
||||
year: nextMonthDate.getFullYear(),
|
||||
inMonth: false,
|
||||
isToday: key === getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
isSelected: key === selectedDateKey
|
||||
isToday:
|
||||
key ===
|
||||
getDateKey(today.getFullYear(), today.getMonth(), today.getDate()),
|
||||
isSelected: key === selectedDateKey,
|
||||
});
|
||||
}
|
||||
|
||||
return nextCells;
|
||||
}
|
||||
|
||||
$: monthLabel = new Date(currentYear, currentMonth, 1).toLocaleString(undefined, { month: "long" });
|
||||
$: monthLabel = new Date(currentYear, currentMonth, 1).toLocaleString(
|
||||
undefined,
|
||||
{ month: "long" },
|
||||
);
|
||||
$: cells = getCalendarCells(currentYear, currentMonth);
|
||||
$: onVisibleMonthChange({ year: currentYear, month: currentMonth, label: monthLabel });
|
||||
$: onVisibleMonthChange({
|
||||
year: currentYear,
|
||||
month: currentMonth,
|
||||
label: monthLabel,
|
||||
});
|
||||
$: {
|
||||
const parts = selectedDateKey.split("-");
|
||||
const [year, month, day] = parts.map((value) => Number(value));
|
||||
if (parts.length === 3 && !Number.isNaN(year) && !Number.isNaN(month) && !Number.isNaN(day)) {
|
||||
onSelectedDateChange({ year, month: month - 1, day, key: selectedDateKey });
|
||||
if (
|
||||
parts.length === 3 &&
|
||||
!Number.isNaN(year) &&
|
||||
!Number.isNaN(month) &&
|
||||
!Number.isNaN(day)
|
||||
) {
|
||||
onSelectedDateChange({
|
||||
year,
|
||||
month: month - 1,
|
||||
day,
|
||||
key: selectedDateKey,
|
||||
});
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="calendar-widget" aria-label="Monthly calendar">
|
||||
<header class="calendar-header">
|
||||
<button type="button" class="nav-icon" aria-label="Previous month" on:click={() => changeMonth(-1)}>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-icon"
|
||||
aria-label="Previous month"
|
||||
on:click={() => changeMonth(-1)}
|
||||
>
|
||||
<span class="material-symbols-outlined">chevron_left</span>
|
||||
</button>
|
||||
|
||||
@ -117,7 +179,12 @@
|
||||
<span>{currentYear}</span>
|
||||
</div>
|
||||
|
||||
<button type="button" class="nav-icon" aria-label="Next month" on:click={() => changeMonth(1)}>
|
||||
<button
|
||||
type="button"
|
||||
class="nav-icon"
|
||||
aria-label="Next month"
|
||||
on:click={() => changeMonth(1)}
|
||||
>
|
||||
<span class="material-symbols-outlined">chevron_right</span>
|
||||
</button>
|
||||
</header>
|
||||
@ -139,7 +206,7 @@
|
||||
aria-label={`Day ${cell.day}`}
|
||||
on:click={() => selectCell(cell)}
|
||||
>
|
||||
{cell.day}
|
||||
<span class="day-number">{cell.day}</span>
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
@ -213,12 +280,20 @@
|
||||
}
|
||||
|
||||
.calendar-cell {
|
||||
height: 30px;
|
||||
height: 36px;
|
||||
border-radius: 7px;
|
||||
border: 1px solid transparent;
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-muted);
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.day-number {
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.calendar-cell:hover {
|
||||
|
||||
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>
|
||||
@ -1,20 +1,173 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
import FragmentEditor from "$lib/components/editor/FragmentEditor.svelte";
|
||||
import ListEditor from "$lib/components/editor/ListEditor.svelte";
|
||||
import TodoEditor from "$lib/components/editor/TodoEditor.svelte";
|
||||
import MarkdownEditor from "$lib/components/editor/MarkdownEditor.svelte";
|
||||
import CoachPanel from "$lib/components/CoachPanel.svelte";
|
||||
import { aiStatusStore, coachStateStore } from "$lib/stores/ai";
|
||||
|
||||
export let activeSection = "entries";
|
||||
export let openDocumentId = "entries/daily-notes";
|
||||
export let openDocumentName = "Daily Notes";
|
||||
export let openDocumentContent = "";
|
||||
export let onDocumentContentChange: (content: string) => void = () => {};
|
||||
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
|
||||
export let onOpenDocument: (doc: {
|
||||
id: string;
|
||||
label: string;
|
||||
initialContent: string;
|
||||
linkedFrom?: {
|
||||
id: string;
|
||||
label: string;
|
||||
initialContent: string;
|
||||
section: "entries" | "fragments" | "todos" | "lists" | "calendar";
|
||||
};
|
||||
}) => void = () => {};
|
||||
export let onDeleteDocument: (id: string) => void = () => {};
|
||||
export let showLinkedBackButton = false;
|
||||
export let onLinkedBack: () => void = () => {};
|
||||
export let calendarItems: Array<{
|
||||
id: string;
|
||||
label: string;
|
||||
initialContent: string;
|
||||
}> = [];
|
||||
export let calendarBusy = false;
|
||||
export let calendarError = "";
|
||||
export let previewOnly = true;
|
||||
export let onForceSave: () => Promise<void> | void = () => {};
|
||||
export let onRequestEdit: () => void = () => {};
|
||||
export let onRequestPreview: () => void = () => {};
|
||||
|
||||
type CalendarCard = {
|
||||
id: string;
|
||||
label: string;
|
||||
initialContent: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
hasTrigger: boolean;
|
||||
hasMood: boolean;
|
||||
hasOpenTodos: boolean;
|
||||
};
|
||||
|
||||
function deriveSummary(content: string): string {
|
||||
const lines = content.replace(/\r\n/g, "\n").split("\n");
|
||||
let inFrontmatter = false;
|
||||
let frontmatterDone = false;
|
||||
|
||||
for (const rawLine of lines) {
|
||||
const line = rawLine.trim();
|
||||
if (!frontmatterDone && line === "---") {
|
||||
inFrontmatter = !inFrontmatter;
|
||||
if (!inFrontmatter) frontmatterDone = true;
|
||||
continue;
|
||||
}
|
||||
if (inFrontmatter || !line) continue;
|
||||
if (/^#/.test(line)) continue;
|
||||
if (/^\*\*Date:\*\*/i.test(line)) continue;
|
||||
if (/^Date:/i.test(line)) continue;
|
||||
if (/^(Type:|Tags:)/i.test(line)) continue;
|
||||
return line.length > 180 ? `${line.slice(0, 177)}...` : line;
|
||||
}
|
||||
|
||||
return "No summary available.";
|
||||
}
|
||||
|
||||
function deriveTitle(label: string, content: string): string {
|
||||
const heading = content.match(/^#\s+(.+)$/m)?.[1]?.trim();
|
||||
if (heading) return heading;
|
||||
return label?.trim() || "Untitled Entry";
|
||||
}
|
||||
|
||||
function toCalendarCard(item: {
|
||||
id: string;
|
||||
label: string;
|
||||
initialContent: string;
|
||||
}): CalendarCard {
|
||||
const content = item.initialContent ?? "";
|
||||
const lower = content.toLowerCase();
|
||||
return {
|
||||
...item,
|
||||
title: deriveTitle(item.label, content),
|
||||
summary: deriveSummary(content),
|
||||
hasTrigger:
|
||||
lower.includes("!trigger") ||
|
||||
lower.includes("#trigger") ||
|
||||
lower.includes("#stress"),
|
||||
hasMood:
|
||||
lower.includes("mental / emotional snapshot") ||
|
||||
lower.includes("cognitive state"),
|
||||
hasOpenTodos: /-\s*\[\s\]/.test(content),
|
||||
};
|
||||
}
|
||||
|
||||
$: calendarCards = calendarItems.map(toCalendarCard);
|
||||
</script>
|
||||
|
||||
<main class="editor-panel" aria-label="Editor area">
|
||||
{#if !openDocumentId}
|
||||
{#if showLinkedBackButton}
|
||||
<div class="editor-nav">
|
||||
<button
|
||||
type="button"
|
||||
class="back-btn"
|
||||
on:click={onLinkedBack}
|
||||
aria-label="Back to source entry"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>arrow_back</span
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeSection === "calendar"}
|
||||
<section class="calendar-main" aria-label="Calendar timeline results">
|
||||
<header class="calendar-main-header">
|
||||
<h2>Filtered Entries</h2>
|
||||
</header>
|
||||
{#if calendarBusy}
|
||||
<p class="calendar-copy">Loading timeline...</p>
|
||||
{:else if calendarError}
|
||||
<p class="calendar-copy is-error">{calendarError}</p>
|
||||
{:else if calendarItems.length === 0}
|
||||
<p class="calendar-copy">No entries matched the current filters.</p>
|
||||
{:else}
|
||||
<ul class="calendar-list">
|
||||
{#each calendarCards as item}
|
||||
<li class:is-active={item.id === openDocumentId}>
|
||||
<button
|
||||
type="button"
|
||||
class="calendar-item-btn"
|
||||
on:click={() => onOpenDocument(item)}
|
||||
>
|
||||
<div class="calendar-item-head">
|
||||
<h3>{item.title}</h3>
|
||||
<span class="calendar-date">{item.label}</span>
|
||||
</div>
|
||||
<p class="calendar-summary">{item.summary}</p>
|
||||
<div class="calendar-badges">
|
||||
{#if item.hasMood}<span class="badge mood">Mood</span>{/if}
|
||||
{#if item.hasTrigger}<span class="badge trigger">Trigger</span
|
||||
>{/if}
|
||||
{#if item.hasOpenTodos}<span class="badge todo"
|
||||
>Open To-Dos</span
|
||||
>{/if}
|
||||
</div>
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if activeSection === "coach"}
|
||||
<CoachPanel
|
||||
health={$aiStatusStore.health}
|
||||
healthChecking={$aiStatusStore.checking}
|
||||
coachBusy={$coachStateStore.busy}
|
||||
coachError={$coachStateStore.error}
|
||||
coachPlan={$coachStateStore.plan}
|
||||
coachKind={$coachStateStore.kind}
|
||||
/>
|
||||
{:else if !openDocumentId}
|
||||
<div class="editor-empty">
|
||||
<span class="material-symbols-outlined empty-icon">edit_note</span>
|
||||
<p>Select or create an item to get started</p>
|
||||
@ -36,20 +189,31 @@
|
||||
{openDocumentContent}
|
||||
{onDocumentContentChange}
|
||||
/>
|
||||
{:else if activeSection === "lists"}
|
||||
<ListEditor
|
||||
{openDocumentId}
|
||||
{openDocumentName}
|
||||
{openDocumentContent}
|
||||
{onDocumentContentChange}
|
||||
/>
|
||||
{:else}
|
||||
<MarkdownEditor
|
||||
{openDocumentId}
|
||||
{openDocumentName}
|
||||
{openDocumentContent}
|
||||
{onDocumentContentChange}
|
||||
{onForceSave}
|
||||
{onOpenDocument}
|
||||
{previewOnly}
|
||||
{onRequestEdit}
|
||||
{onRequestPreview}
|
||||
/>
|
||||
{/if}
|
||||
</main>
|
||||
|
||||
<style>
|
||||
.editor-panel {
|
||||
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-editor) 100%);
|
||||
background: var(--bg-editor);
|
||||
padding: 18px 20px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
@ -58,6 +222,30 @@
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.editor-nav {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.back-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 34px;
|
||||
height: 34px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
|
||||
color: var(--text-primary);
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.back-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.editor-empty {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
@ -76,4 +264,145 @@
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
}
|
||||
|
||||
.calendar-main {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
padding: 4px 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.calendar-main-header h2 {
|
||||
font-size: 0.96rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.calendar-copy {
|
||||
font-size: 0.84rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.calendar-copy.is-error {
|
||||
color: #e74c3c;
|
||||
}
|
||||
|
||||
.calendar-list {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.calendar-list li {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: color-mix(in srgb, var(--surface-1) 92%, var(--bg-editor) 8%);
|
||||
}
|
||||
|
||||
.calendar-list li:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.calendar-list li.is-active {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
.calendar-item-btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 10px 12px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.86rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 7px;
|
||||
}
|
||||
|
||||
.calendar-item-head {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.calendar-item-head h3 {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-date {
|
||||
font-size: 0.74rem;
|
||||
color: var(--text-dim);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.calendar-summary {
|
||||
font-size: 0.82rem;
|
||||
color: var(--text-muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.calendar-badges {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.badge {
|
||||
font-size: 0.68rem;
|
||||
border-radius: 999px;
|
||||
border: 1px solid var(--border-soft);
|
||||
padding: 2px 7px;
|
||||
color: var(--text-dim);
|
||||
background: color-mix(in srgb, var(--surface-2) 88%, var(--bg-editor) 12%);
|
||||
}
|
||||
|
||||
.badge.mood {
|
||||
border-color: color-mix(in srgb, #6ba7ff 40%, var(--border-soft) 60%);
|
||||
color: #8dbbff;
|
||||
}
|
||||
|
||||
.badge.trigger {
|
||||
border-color: color-mix(in srgb, #f08c6c 40%, var(--border-soft) 60%);
|
||||
color: #f5ad95;
|
||||
}
|
||||
|
||||
.badge.todo {
|
||||
border-color: color-mix(in srgb, #f2c266 40%, var(--border-soft) 60%);
|
||||
color: #f4d690;
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.editor-panel {
|
||||
padding: 12px 12px calc(18px + env(safe-area-inset-bottom, 0px));
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.calendar-main {
|
||||
padding: 2px 0 12px;
|
||||
}
|
||||
|
||||
.calendar-item-head {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.calendar-item-head h3 {
|
||||
white-space: normal;
|
||||
}
|
||||
|
||||
.calendar-date {
|
||||
white-space: normal;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,3 +1,4 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
export let activeSection: string | null = "entries";
|
||||
export let onSelect: (id: string) => void = () => {};
|
||||
@ -13,7 +14,8 @@
|
||||
{ id: "calendar", label: "Calendar", icon: "calendar_month" },
|
||||
{ id: "fragments", label: "Fragments", icon: "auto_stories" },
|
||||
{ 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) {
|
||||
@ -23,7 +25,7 @@
|
||||
|
||||
<aside class="navbar" aria-label="Primary navigation">
|
||||
<div class="navbar-header">
|
||||
<img src="svelte.svg" alt="Journal logo" />
|
||||
<img src="icon.png" alt="Journal logo" style="height: 48px; width: 48px;" />
|
||||
</div>
|
||||
|
||||
<nav class="nav-groups" aria-label="Journal sections">
|
||||
@ -64,7 +66,11 @@
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
padding: 14px 10px;
|
||||
background: linear-gradient(180deg, var(--surface-2) 0%, var(--bg-navbar) 100%);
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
var(--surface-2) 0%,
|
||||
var(--bg-navbar) 100%
|
||||
);
|
||||
border-right: 1px solid var(--border-soft);
|
||||
}
|
||||
|
||||
@ -102,7 +108,10 @@
|
||||
color: var(--text-dim);
|
||||
border: 1px solid transparent;
|
||||
cursor: pointer;
|
||||
transition: background-color 0.14s ease, color 0.14s ease, border-color 0.14s ease;
|
||||
transition:
|
||||
background-color 0.14s ease,
|
||||
color 0.14s ease,
|
||||
border-color 0.14s ease;
|
||||
}
|
||||
|
||||
.nav-button .material-symbols-outlined {
|
||||
@ -129,7 +138,9 @@
|
||||
background: var(--surface-1);
|
||||
border: 1px solid var(--border-strong);
|
||||
box-shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
|
||||
transition: opacity 0.12s ease, transform 0.12s ease;
|
||||
transition:
|
||||
opacity 0.12s ease,
|
||||
transform 0.12s ease;
|
||||
}
|
||||
|
||||
.nav-button:hover,
|
||||
@ -180,4 +191,44 @@
|
||||
height: 40px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 820px) {
|
||||
.navbar {
|
||||
gap: 10px;
|
||||
padding: calc(10px + env(safe-area-inset-top, 0px)) 6px 10px;
|
||||
}
|
||||
|
||||
.navbar-header {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.nav-groups {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.nav-group {
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.nav-button,
|
||||
.settings-chip {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 9px;
|
||||
}
|
||||
|
||||
.settings-chip {
|
||||
margin-top: auto;
|
||||
}
|
||||
|
||||
.nav-tooltip {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.nav-button .material-symbols-outlined,
|
||||
.settings-chip .material-symbols-outlined {
|
||||
font-size: 1.08rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,4 +1,11 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
import {
|
||||
probeMicrophoneAccess,
|
||||
startSpeechDictation,
|
||||
stopSpeechDictation,
|
||||
} from "$lib/backend/speech";
|
||||
import { isTauriRuntime } from "$lib/runtime/invoke";
|
||||
import {
|
||||
createFragmentFromParsed,
|
||||
deleteFragmentByStoreId,
|
||||
@ -7,17 +14,22 @@
|
||||
parseFragmentContent,
|
||||
serializeFragment,
|
||||
updateFragmentFromParsed,
|
||||
type FragmentItem
|
||||
type FragmentItem,
|
||||
} from "$lib/stores/fragments";
|
||||
import { settingsFragmentTypes, settingsTags } from "$lib/stores/settings";
|
||||
import { renderMarkdown } from "$lib/utils/markdown";
|
||||
import { onDestroy, onMount } from "svelte";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
export let openDocumentId = "";
|
||||
export let openDocumentName = "";
|
||||
export let openDocumentContent = "";
|
||||
export let onDocumentContentChange: (content: string) => void = () => {};
|
||||
export let onOpenDocument: (doc: { id: string; label: string; initialContent: string }) => void = () => {};
|
||||
export let onOpenDocument: (doc: {
|
||||
id: string;
|
||||
label: string;
|
||||
initialContent: string;
|
||||
}) => void = () => {};
|
||||
export let onDeleteDocument: (id: string) => void = () => {};
|
||||
export let externalEditRequested = false;
|
||||
|
||||
@ -31,21 +43,40 @@
|
||||
let lastFragmentDocumentId = "";
|
||||
let fragmentTypeOptions: string[] = [];
|
||||
let tagOptions: string[] = [];
|
||||
let suppressExternalEditRequest = false;
|
||||
let dictationBusy = false;
|
||||
let dictationActive = false;
|
||||
let dictationError = "";
|
||||
let dictationStatus = "";
|
||||
let unlistenTranscript: (() => void) | null = null;
|
||||
let unlistenSpeechStatus: (() => void) | null = null;
|
||||
const customTypeValue = "__custom_type__";
|
||||
const customTagValue = "__custom_tag__";
|
||||
|
||||
function buildFragmentContent(): { title: string; resolvedType: string; body: string; content: string; tags: string[] } | null {
|
||||
function buildFragmentContent(): {
|
||||
title: string;
|
||||
resolvedType: string;
|
||||
body: string;
|
||||
content: string;
|
||||
tags: string[];
|
||||
} | null {
|
||||
const title = fragmentTitle.trim();
|
||||
if (!title) return null;
|
||||
const resolvedType = fragmentType === customTypeValue ? customFragmentType.trim() : fragmentType;
|
||||
const resolvedType =
|
||||
fragmentType === customTypeValue
|
||||
? customFragmentType.trim()
|
||||
: fragmentType;
|
||||
if (!resolvedType) return null;
|
||||
const selectedTags = fragmentTag && fragmentTag !== customTagValue ? [fragmentTag] : [];
|
||||
const selectedTags =
|
||||
fragmentTag && fragmentTag !== customTagValue ? [fragmentTag] : [];
|
||||
const customTags = customFragmentTags
|
||||
.split(",")
|
||||
.map((tag) => tag.trim())
|
||||
.filter(Boolean);
|
||||
const tagList = [...selectedTags, ...customTags];
|
||||
const uniqueTagList = [...new Set(tagList.map((tag) => tag.toLowerCase()))].map((lower) => {
|
||||
const uniqueTagList = [
|
||||
...new Set(tagList.map((tag) => tag.toLowerCase())),
|
||||
].map((lower) => {
|
||||
return tagList.find((tag) => tag.toLowerCase() === lower) ?? lower;
|
||||
});
|
||||
const body = fragmentBody.trim() || "Add details for this fragment.";
|
||||
@ -53,7 +84,7 @@
|
||||
title,
|
||||
type: resolvedType,
|
||||
tags: uniqueTagList,
|
||||
body
|
||||
body,
|
||||
});
|
||||
return { title, resolvedType, body, content, tags: uniqueTagList };
|
||||
}
|
||||
@ -72,7 +103,7 @@
|
||||
title: payload.title,
|
||||
type: payload.resolvedType,
|
||||
tags: payload.tags,
|
||||
body: payload.body
|
||||
body: payload.body,
|
||||
});
|
||||
if (!updated) return;
|
||||
|
||||
@ -91,7 +122,7 @@
|
||||
title: payload.title,
|
||||
type: payload.resolvedType,
|
||||
tags: payload.tags,
|
||||
body: payload.body
|
||||
body: payload.body,
|
||||
});
|
||||
onOpenDocument(item);
|
||||
fragmentMode = "view";
|
||||
@ -127,18 +158,26 @@
|
||||
}
|
||||
|
||||
function cancelFragmentEdit() {
|
||||
if (dictationActive) {
|
||||
void stopDictation();
|
||||
}
|
||||
if (fragmentMode === "create") {
|
||||
fragmentMode = "view";
|
||||
suppressExternalEditRequest = true;
|
||||
return;
|
||||
}
|
||||
loadFragmentFormFromDocument();
|
||||
fragmentMode = "view";
|
||||
suppressExternalEditRequest = true;
|
||||
}
|
||||
|
||||
function loadFragmentFormFromDocument() {
|
||||
const content = openDocumentContent ?? "";
|
||||
const isDraftFragment = openDocumentId.startsWith("fragments/new-");
|
||||
const parsed = parseFragmentContent(content, openDocumentName || "Untitled Fragment");
|
||||
const parsed = parseFragmentContent(
|
||||
content,
|
||||
openDocumentName || "Untitled Fragment",
|
||||
);
|
||||
fragmentTitle = parsed.title;
|
||||
const parsedType = parsed.type;
|
||||
if (!parsedType) {
|
||||
@ -172,32 +211,214 @@
|
||||
fragmentMode = isDraftFragment ? "create" : "view";
|
||||
}
|
||||
|
||||
$: fragmentTypeOptions = $settingsFragmentTypes.length ? $settingsFragmentTypes : ["General"];
|
||||
function appendDictationChunk(text: string) {
|
||||
const cleaned = text.trim();
|
||||
if (!cleaned) return;
|
||||
const prefix =
|
||||
fragmentBody.length > 0 &&
|
||||
!fragmentBody.endsWith(" ") &&
|
||||
!fragmentBody.endsWith("\n") &&
|
||||
!fragmentBody.endsWith("\t")
|
||||
? " "
|
||||
: "";
|
||||
fragmentBody = `${fragmentBody}${prefix}${cleaned} `;
|
||||
}
|
||||
|
||||
async function startDictation() {
|
||||
if (dictationBusy || dictationActive) return;
|
||||
if (!isTauriRuntime()) {
|
||||
dictationError = "Speech dictation is available in the desktop app only.";
|
||||
return;
|
||||
}
|
||||
|
||||
dictationBusy = true;
|
||||
dictationError = "";
|
||||
dictationStatus = "Checking microphone access...";
|
||||
try {
|
||||
await probeMicrophoneAccess();
|
||||
dictationStatus = "Starting dictation...";
|
||||
const started = await Promise.race([
|
||||
startSpeechDictation(),
|
||||
new Promise<never>((_, reject) =>
|
||||
setTimeout(
|
||||
() =>
|
||||
reject(
|
||||
new Error("Timed out waiting for speech process startup."),
|
||||
),
|
||||
8000,
|
||||
),
|
||||
),
|
||||
]);
|
||||
dictationActive = true;
|
||||
if (started?.pid) {
|
||||
dictationStatus = "Dictation started.";
|
||||
}
|
||||
} catch (error) {
|
||||
dictationError = String(error);
|
||||
dictationActive = false;
|
||||
} finally {
|
||||
dictationBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function stopDictation() {
|
||||
if (dictationBusy || !dictationActive) return;
|
||||
dictationBusy = true;
|
||||
dictationError = "";
|
||||
try {
|
||||
await stopSpeechDictation();
|
||||
} catch (error) {
|
||||
dictationError = String(error);
|
||||
} finally {
|
||||
dictationActive = false;
|
||||
dictationStatus = "Dictation stopped.";
|
||||
dictationBusy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleDictation() {
|
||||
if (dictationActive) {
|
||||
await stopDictation();
|
||||
return;
|
||||
}
|
||||
|
||||
await startDictation();
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
if (!isTauriRuntime()) return;
|
||||
|
||||
let disposed = false;
|
||||
void (async () => {
|
||||
const { listen } = await import("@tauri-apps/api/event");
|
||||
const unlisten = await listen<{ text?: string }>(
|
||||
"speech-transcript",
|
||||
(event) => {
|
||||
if (disposed || !dictationActive) return;
|
||||
if (fragmentMode !== "edit" && fragmentMode !== "create") return;
|
||||
const text = event.payload?.text ?? "";
|
||||
appendDictationChunk(text);
|
||||
},
|
||||
);
|
||||
const unlistenStatus = await listen<{ state?: string; message?: string }>(
|
||||
"speech-status",
|
||||
(event) => {
|
||||
const state = event.payload?.state ?? "";
|
||||
const message = event.payload?.message ?? "";
|
||||
if (message) {
|
||||
dictationStatus = message;
|
||||
}
|
||||
|
||||
if (state === "listening") {
|
||||
dictationError = "";
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "error") {
|
||||
dictationError = message || "Speech process error.";
|
||||
dictationActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
if (state === "stopped") {
|
||||
dictationActive = false;
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
if (disposed) {
|
||||
unlisten();
|
||||
return;
|
||||
}
|
||||
|
||||
unlistenTranscript = unlisten;
|
||||
unlistenSpeechStatus = unlistenStatus;
|
||||
})();
|
||||
|
||||
return () => {
|
||||
disposed = true;
|
||||
if (unlistenTranscript) {
|
||||
unlistenTranscript();
|
||||
unlistenTranscript = null;
|
||||
}
|
||||
if (unlistenSpeechStatus) {
|
||||
unlistenSpeechStatus();
|
||||
unlistenSpeechStatus = null;
|
||||
}
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (dictationActive) {
|
||||
void stopDictation();
|
||||
}
|
||||
});
|
||||
|
||||
$: fragmentTypeOptions = $settingsFragmentTypes.length
|
||||
? $settingsFragmentTypes
|
||||
: ["General"];
|
||||
$: tagOptions = $settingsTags;
|
||||
$: if (openDocumentId !== lastFragmentDocumentId) {
|
||||
loadFragmentFormFromDocument();
|
||||
lastFragmentDocumentId = openDocumentId;
|
||||
}
|
||||
$: if (!fragmentType || (!fragmentTypeOptions.includes(fragmentType) && fragmentType !== customTypeValue)) {
|
||||
$: if (
|
||||
!fragmentType ||
|
||||
(!fragmentTypeOptions.includes(fragmentType) &&
|
||||
fragmentType !== customTypeValue)
|
||||
) {
|
||||
fragmentType = fragmentTypeOptions[0];
|
||||
}
|
||||
$: if (!fragmentTag || (!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)) {
|
||||
$: if (
|
||||
!fragmentTag ||
|
||||
(!tagOptions.includes(fragmentTag) && fragmentTag !== customTagValue)
|
||||
) {
|
||||
fragmentTag = tagOptions[0] ?? customTagValue;
|
||||
}
|
||||
$: if (externalEditRequested && fragmentMode === "view") {
|
||||
$: if (!externalEditRequested) {
|
||||
suppressExternalEditRequest = false;
|
||||
}
|
||||
$: if (
|
||||
externalEditRequested &&
|
||||
!suppressExternalEditRequest &&
|
||||
fragmentMode === "view"
|
||||
) {
|
||||
fragmentMode = "edit";
|
||||
}
|
||||
$: if (fragmentMode === "view" && dictationActive) {
|
||||
void stopDictation();
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="fragment-surface">
|
||||
{#if dictationError || dictationStatus}
|
||||
<div
|
||||
class="dictation-indicator"
|
||||
class:is-error={Boolean(dictationError)}
|
||||
role="status"
|
||||
aria-live="polite"
|
||||
>
|
||||
{dictationError || dictationStatus}
|
||||
</div>
|
||||
{/if}
|
||||
{#if fragmentMode === "view"}
|
||||
<article class="fragment-view">
|
||||
{@html renderMarkdown(openDocumentContent)}
|
||||
</article>
|
||||
{:else}
|
||||
<form class="fragment-form" on:submit|preventDefault={fragmentMode === "create" ? createNewFragment : saveFragmentEdits}>
|
||||
<form
|
||||
class="fragment-form"
|
||||
on:submit|preventDefault={fragmentMode === "create"
|
||||
? createNewFragment
|
||||
: saveFragmentEdits}
|
||||
>
|
||||
<h2>{fragmentMode === "create" ? "New Fragment" : "Edit Fragment"}</h2>
|
||||
<input type="text" placeholder="Title" bind:value={fragmentTitle} aria-label="Fragment title" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Title"
|
||||
bind:value={fragmentTitle}
|
||||
aria-label="Fragment title"
|
||||
/>
|
||||
<div class="fragment-form-row">
|
||||
<select bind:value={fragmentType} aria-label="Fragment type">
|
||||
{#each fragmentTypeOptions as type}
|
||||
@ -213,7 +434,12 @@
|
||||
aria-label="Custom fragment type"
|
||||
/>
|
||||
{:else}
|
||||
<input type="text" value={fragmentType} disabled aria-label="Selected fragment type" />
|
||||
<input
|
||||
type="text"
|
||||
value={fragmentType}
|
||||
disabled
|
||||
aria-label="Selected fragment type"
|
||||
/>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="fragment-form-row">
|
||||
@ -243,8 +469,27 @@
|
||||
aria-label="Fragment body"
|
||||
></textarea>
|
||||
<div class="fragment-actions">
|
||||
<button type="submit" class="fragment-submit">{fragmentMode === "create" ? "Create Fragment" : "Save Fragment"}</button>
|
||||
<button type="button" class="fragment-secondary" on:click={cancelFragmentEdit}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="fragment-secondary"
|
||||
class:is-active={dictationActive}
|
||||
on:click={() => void toggleDictation()}
|
||||
disabled={dictationBusy || !isTauriRuntime()}
|
||||
aria-label={dictationActive ? "Stop dictation" : "Start dictation"}
|
||||
title={dictationActive ? "Stop dictation" : "Start dictation"}
|
||||
>
|
||||
{dictationActive ? "Stop Dictation" : "Start Dictation"}
|
||||
</button>
|
||||
<button type="submit" class="fragment-submit"
|
||||
>{fragmentMode === "create"
|
||||
? "Create Fragment"
|
||||
: "Save Fragment"}</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="fragment-secondary"
|
||||
on:click={cancelFragmentEdit}>Cancel</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
@ -254,34 +499,63 @@
|
||||
.fragment-surface {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: auto;
|
||||
padding: 0 14px 14px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.dictation-indicator {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 22px;
|
||||
z-index: 30;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--surface-1) 94%, var(--bg-editor) 6%);
|
||||
color: var(--text-dim);
|
||||
font-size: 0.76rem;
|
||||
line-height: 1.35;
|
||||
padding: 6px 9px;
|
||||
max-width: min(56ch, 60%);
|
||||
box-shadow: 0 8px 20px
|
||||
color-mix(in srgb, var(--bg-app) 35%, transparent 65%);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.dictation-indicator.is-error {
|
||||
color: #fca5a5;
|
||||
border-color: color-mix(in srgb, #ef4444 45%, var(--border-soft) 55%);
|
||||
background: color-mix(in srgb, #2a1414 52%, var(--surface-1) 48%);
|
||||
}
|
||||
|
||||
.fragment-form {
|
||||
width: min(760px, 100%);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-1);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25);
|
||||
padding: 14px;
|
||||
width: min(100%, 920px);
|
||||
margin: 0 auto;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 28px 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.fragment-view {
|
||||
width: min(760px, 100%);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-1);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25);
|
||||
padding: 14px;
|
||||
width: min(100%, 920px);
|
||||
margin: 0 auto;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 28px 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.92rem;
|
||||
line-height: 1.65;
|
||||
font-family:
|
||||
"Segoe UI Variable", "Segoe UI", "Segoe UI Emoji", "Apple Color Emoji",
|
||||
"Noto Color Emoji", Inter, Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.fragment-view :global(h1),
|
||||
@ -310,10 +584,30 @@
|
||||
font-size: 0.82rem;
|
||||
}
|
||||
|
||||
.fragment-view :global(.markdown-tag-list) {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
vertical-align: middle;
|
||||
}
|
||||
|
||||
.fragment-view :global(.markdown-tag-chip) {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 999px;
|
||||
padding: 1px 8px;
|
||||
font-size: 0.74rem;
|
||||
line-height: 1.35;
|
||||
color: var(--text-muted);
|
||||
background: color-mix(in srgb, var(--surface-2) 90%, var(--bg-editor) 10%);
|
||||
}
|
||||
|
||||
.fragment-form h2 {
|
||||
font-size: 0.94rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.fragment-form input,
|
||||
@ -322,15 +616,23 @@
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-app);
|
||||
background-color: color-mix(
|
||||
in srgb,
|
||||
var(--surface-1) 88%,
|
||||
var(--bg-editor) 12%
|
||||
);
|
||||
color: var(--text-primary);
|
||||
padding: 9px 10px;
|
||||
font-size: 0.86rem;
|
||||
padding: 10px 11px;
|
||||
font-size: 0.88rem;
|
||||
font-family:
|
||||
"Segoe UI Variable", "Segoe UI", "Segoe UI Emoji", "Apple Color Emoji",
|
||||
"Noto Color Emoji", Inter, Roboto, Helvetica, Arial, sans-serif;
|
||||
}
|
||||
|
||||
.fragment-form textarea {
|
||||
resize: vertical;
|
||||
min-height: 160px;
|
||||
min-height: 220px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
.fragment-form-row {
|
||||
@ -343,7 +645,7 @@
|
||||
width: fit-content;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
font-size: 0.82rem;
|
||||
@ -363,7 +665,7 @@
|
||||
.fragment-secondary {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
|
||||
color: var(--text-muted);
|
||||
padding: 8px 12px;
|
||||
font-size: 0.82rem;
|
||||
@ -374,4 +676,33 @@
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.fragment-secondary.is-active {
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
background: var(--bg-active);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.fragment-surface {
|
||||
padding: 4px 8px 10px;
|
||||
}
|
||||
|
||||
.dictation-indicator {
|
||||
right: 10px;
|
||||
top: 6px;
|
||||
max-width: calc(100% - 20px);
|
||||
}
|
||||
|
||||
.fragment-form,
|
||||
.fragment-view {
|
||||
width: 100%;
|
||||
padding: 18px 16px;
|
||||
font-size: 0.89rem;
|
||||
}
|
||||
|
||||
.fragment-form-row {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
303
Journal.App/src/lib/components/editor/ListEditor.svelte
Normal file
@ -0,0 +1,303 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
export let openDocumentId = "";
|
||||
export let openDocumentName = "";
|
||||
export let openDocumentContent = "";
|
||||
export let onDocumentContentChange: (content: string) => void = () => {};
|
||||
|
||||
type SimpleListItem = {
|
||||
id: number;
|
||||
text: string;
|
||||
};
|
||||
|
||||
let items: SimpleListItem[] = [];
|
||||
let nextItemId = 1;
|
||||
let lastDocumentId = "";
|
||||
let newItemText = "";
|
||||
let editingItemId: number | null = null;
|
||||
let editingItemText = "";
|
||||
|
||||
function parseListItems(content: string): SimpleListItem[] {
|
||||
const lines = (content ?? "").split(/\r?\n/);
|
||||
const parsed: SimpleListItem[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
const bullet = line.match(/^\s*(?:[-*+]|\d+\.)\s+(.+)$/);
|
||||
if (bullet) {
|
||||
parsed.push({ id: parsed.length + 1, text: bullet[1].trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith("#")) continue;
|
||||
parsed.push({ id: parsed.length + 1, text: trimmed });
|
||||
}
|
||||
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function serializeList(title: string, listItems: SimpleListItem[]): string {
|
||||
const heading = (title ?? "").trim() || "Untitled List";
|
||||
if (!listItems.length) {
|
||||
return `# ${heading}\n\n`;
|
||||
}
|
||||
|
||||
const body = listItems
|
||||
.map((item) => item.text.trim())
|
||||
.filter(Boolean)
|
||||
.map((text) => `- ${text}`)
|
||||
.join("\n");
|
||||
|
||||
return `# ${heading}\n\n${body}`;
|
||||
}
|
||||
|
||||
function persist() {
|
||||
onDocumentContentChange(serializeList(openDocumentName, items));
|
||||
}
|
||||
|
||||
function resetForDocument() {
|
||||
items = parseListItems(openDocumentContent);
|
||||
nextItemId = (items[items.length - 1]?.id ?? 0) + 1;
|
||||
newItemText = "";
|
||||
editingItemId = null;
|
||||
editingItemText = "";
|
||||
}
|
||||
|
||||
function addItem() {
|
||||
const text = newItemText.trim();
|
||||
if (!text) return;
|
||||
items = [{ id: nextItemId, text }, ...items];
|
||||
nextItemId += 1;
|
||||
newItemText = "";
|
||||
persist();
|
||||
}
|
||||
|
||||
function startEditItem(id: number) {
|
||||
const existing = items.find((item) => item.id === id);
|
||||
if (!existing) return;
|
||||
editingItemId = id;
|
||||
editingItemText = existing.text;
|
||||
}
|
||||
|
||||
function saveEditItem() {
|
||||
if (editingItemId === null) return;
|
||||
const text = editingItemText.trim();
|
||||
if (!text) return;
|
||||
const id = editingItemId;
|
||||
items = items.map((item) => (item.id === id ? { ...item, text } : item));
|
||||
editingItemId = null;
|
||||
editingItemText = "";
|
||||
persist();
|
||||
}
|
||||
|
||||
function cancelEditItem() {
|
||||
editingItemId = null;
|
||||
editingItemText = "";
|
||||
}
|
||||
|
||||
function removeItem(id: number) {
|
||||
if (editingItemId === id) {
|
||||
cancelEditItem();
|
||||
}
|
||||
items = items.filter((item) => item.id !== id);
|
||||
persist();
|
||||
}
|
||||
|
||||
$: if (openDocumentId !== lastDocumentId) {
|
||||
resetForDocument();
|
||||
lastDocumentId = openDocumentId;
|
||||
}
|
||||
</script>
|
||||
|
||||
<section class="list-surface">
|
||||
<div class="list-card">
|
||||
<form class="list-create" on:submit|preventDefault={addItem}>
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Add a list item"
|
||||
bind:value={newItemText}
|
||||
aria-label="Add list item"
|
||||
/>
|
||||
<button type="submit" class="list-add-btn">Add</button>
|
||||
</form>
|
||||
|
||||
<ul class="list-items">
|
||||
{#each items as item}
|
||||
<li class="list-item">
|
||||
{#if editingItemId === item.id}
|
||||
<input
|
||||
type="text"
|
||||
class="list-edit-input"
|
||||
bind:value={editingItemText}
|
||||
on:keydown={(event) => {
|
||||
if (event.key === "Enter") saveEditItem();
|
||||
if (event.key === "Escape") cancelEditItem();
|
||||
}}
|
||||
/>
|
||||
<div class="list-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="list-btn save"
|
||||
on:click={saveEditItem}>Save</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="list-btn ghost"
|
||||
on:click={cancelEditItem}>Cancel</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="list-text">{item.text}</span>
|
||||
<div class="list-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="list-btn ghost"
|
||||
on:click={() => startEditItem(item.id)}>Edit</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="list-btn danger"
|
||||
on:click={() => removeItem(item.id)}>Remove</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<style>
|
||||
.list-surface {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
overflow: auto;
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
|
||||
.list-card {
|
||||
width: min(100%, 920px);
|
||||
margin: 0 auto;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 28px 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
max-height: 100%;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.list-create {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.list-create input,
|
||||
.list-edit-input {
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--surface-1) 88%, var(--bg-editor) 12%);
|
||||
color: var(--text-primary);
|
||||
padding: 10px 11px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.list-add-btn {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
|
||||
color: var(--text-primary);
|
||||
padding: 9px 14px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-add-btn:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.list-items {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
padding: 10px 12px;
|
||||
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
|
||||
}
|
||||
|
||||
.list-text {
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.list-actions {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.list-btn {
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
|
||||
color: var(--text-muted);
|
||||
padding: 6px 10px;
|
||||
font-size: 0.78rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.list-btn.save {
|
||||
border-color: var(--border-strong);
|
||||
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.list-btn.danger:hover,
|
||||
.list-btn.ghost:hover,
|
||||
.list-btn.save:hover {
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.list-surface {
|
||||
padding: 4px 8px 10px;
|
||||
}
|
||||
|
||||
.list-card {
|
||||
width: 100%;
|
||||
padding: 18px 16px;
|
||||
}
|
||||
|
||||
.list-create {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.list-add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.list-item {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.list-actions {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
522
Journal.App/src/lib/components/editor/MarkdownToolbar.svelte
Normal file
@ -0,0 +1,522 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
import type { EntryTemplateItemDto } from "$lib/backend/templates";
|
||||
type AttachmentOption = { id: string; label: string };
|
||||
|
||||
export let isEntryDocument = false;
|
||||
export let templatesBusy = false;
|
||||
export let templateOptions: EntryTemplateItemDto[] = [];
|
||||
export let listMode: "ul" | "ol" | null = null;
|
||||
export let attachmentsDisabled = false;
|
||||
|
||||
export let onApplyHeading: (level: number) => void = () => {};
|
||||
export let onApplyTemplate: (filePath: string) => void = () => {};
|
||||
export let onOpenAttachments: () => void = () => {};
|
||||
export let onBold: () => void = () => {};
|
||||
export let onItalic: () => void = () => {};
|
||||
export let onUnderline: () => void = () => {};
|
||||
export let onTag: () => void = () => {};
|
||||
export let onLink: () => void = () => {};
|
||||
export let onToggleUl: () => void = () => {};
|
||||
export let onToggleOl: () => void = () => {};
|
||||
export let onCode: () => void = () => {};
|
||||
export let onSave: () => void = () => {};
|
||||
export let saveBusy = false;
|
||||
export let onToggleDictation: () => void = () => {};
|
||||
export let dictationActive = false;
|
||||
export let dictationBusy = false;
|
||||
export let dictationUnavailable = false;
|
||||
|
||||
let headingMenuOpen = false;
|
||||
let headingMenuEl: HTMLDivElement | null = null;
|
||||
let templateMenuOpen = false;
|
||||
let templateMenuEl: HTMLDivElement | null = null;
|
||||
|
||||
function toggleHeadingMenu() {
|
||||
headingMenuOpen = !headingMenuOpen;
|
||||
}
|
||||
|
||||
function selectHeading(level: number) {
|
||||
onApplyHeading(level);
|
||||
headingMenuOpen = false;
|
||||
}
|
||||
|
||||
function handleHeadingFocusOut(event: FocusEvent) {
|
||||
const next = event.relatedTarget as Node | null;
|
||||
if (headingMenuEl && next && headingMenuEl.contains(next)) return;
|
||||
headingMenuOpen = false;
|
||||
}
|
||||
|
||||
function toggleTemplateMenu() {
|
||||
if (templatesBusy) return;
|
||||
templateMenuOpen = !templateMenuOpen;
|
||||
}
|
||||
|
||||
function selectTemplate(filePath: string) {
|
||||
onApplyTemplate(filePath);
|
||||
templateMenuOpen = false;
|
||||
}
|
||||
|
||||
function handleTemplateFocusOut(event: FocusEvent) {
|
||||
const next = event.relatedTarget as Node | null;
|
||||
if (templateMenuEl && next && templateMenuEl.contains(next)) return;
|
||||
templateMenuOpen = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="editor-toolbar">
|
||||
<div class="toolbar-group">
|
||||
<div
|
||||
class="toolbar-select-wrap heading-wrap"
|
||||
bind:this={headingMenuEl}
|
||||
on:focusout={handleHeadingFocusOut}
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">title</span>
|
||||
<button
|
||||
type="button"
|
||||
class="heading-trigger"
|
||||
aria-label="Heading"
|
||||
title="Heading"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={headingMenuOpen}
|
||||
on:click={toggleHeadingMenu}
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>expand_more</span
|
||||
>
|
||||
</button>
|
||||
{#if headingMenuOpen}
|
||||
<div class="heading-menu" role="listbox" aria-label="Header size">
|
||||
<button
|
||||
type="button"
|
||||
class="heading-option"
|
||||
on:click={() => selectHeading(1)}>H1</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="heading-option"
|
||||
on:click={() => selectHeading(2)}>H2</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="heading-option"
|
||||
on:click={() => selectHeading(3)}>H3</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="heading-option"
|
||||
on:click={() => selectHeading(4)}>H4</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="heading-option"
|
||||
on:click={() => selectHeading(5)}>H5</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="heading-option"
|
||||
on:click={() => selectHeading(6)}>H6</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
{#if isEntryDocument}
|
||||
<div
|
||||
class="toolbar-select-wrap template-wrap"
|
||||
bind:this={templateMenuEl}
|
||||
on:focusout={handleTemplateFocusOut}
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>description</span
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="template-trigger"
|
||||
aria-label="Insert template"
|
||||
title="Insert template"
|
||||
aria-haspopup="listbox"
|
||||
aria-expanded={templateMenuOpen}
|
||||
disabled={templatesBusy}
|
||||
on:click={toggleTemplateMenu}
|
||||
>
|
||||
<span class="template-trigger-text"
|
||||
>{templatesBusy ? "Loading..." : "Template"}</span
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>expand_more</span
|
||||
>
|
||||
</button>
|
||||
{#if templateMenuOpen}
|
||||
<div class="template-menu" role="listbox" aria-label="Template">
|
||||
{#if templateOptions.length === 0}
|
||||
<div class="template-empty">No templates</div>
|
||||
{:else}
|
||||
{#each templateOptions as template}
|
||||
<button
|
||||
type="button"
|
||||
class="template-option"
|
||||
on:click={() => selectTemplate(template.filePath)}
|
||||
>
|
||||
{template.fileName.replace(/\.template\.md$/i, "")}
|
||||
</button>
|
||||
{/each}
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn toolbar-icon-btn"
|
||||
on:click={onOpenAttachments}
|
||||
disabled={attachmentsDisabled}
|
||||
aria-label="Attach item"
|
||||
title="Attach item"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">add</span>
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="toolbar-divider" aria-hidden="true"></div>
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn toolbar-icon-btn"
|
||||
on:click={onBold}
|
||||
aria-label="Bold"
|
||||
title="Bold"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>format_bold</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn toolbar-icon-btn"
|
||||
on:click={onItalic}
|
||||
aria-label="Italic"
|
||||
title="Italic"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>format_italic</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn toolbar-icon-btn"
|
||||
on:click={onUnderline}
|
||||
aria-label="Underline"
|
||||
title="Underline"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>format_underlined</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn toolbar-icon-btn"
|
||||
on:click={onTag}
|
||||
aria-label="Tag"
|
||||
title="Tag [[...]]"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">sell</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn toolbar-icon-btn"
|
||||
on:click={onLink}
|
||||
aria-label="Link"
|
||||
title="Link"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">link</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn toolbar-icon-btn"
|
||||
class:is-active={listMode === "ul"}
|
||||
on:click={onToggleUl}
|
||||
aria-label="Bulleted list"
|
||||
title="Bulleted list"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>format_list_bulleted</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn toolbar-icon-btn"
|
||||
class:is-active={listMode === "ol"}
|
||||
on:click={onToggleOl}
|
||||
aria-label="Numbered list"
|
||||
title="Numbered list"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>format_list_numbered</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn toolbar-icon-btn"
|
||||
on:click={onCode}
|
||||
aria-label="Inline code"
|
||||
title="Inline code"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">code</span>
|
||||
</button>
|
||||
</div>
|
||||
<div class="toolbar-divider" aria-hidden="true"></div>
|
||||
<div class="toolbar-group">
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn toolbar-icon-btn"
|
||||
class:is-active={dictationActive}
|
||||
on:click={onToggleDictation}
|
||||
disabled={dictationBusy || dictationUnavailable}
|
||||
aria-label={dictationActive ? "Stop dictation" : "Start dictation"}
|
||||
title={dictationActive ? "Stop dictation" : "Start dictation"}
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true"
|
||||
>{dictationActive ? "mic_off" : "mic"}</span
|
||||
>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="toolbar-btn toolbar-icon-btn"
|
||||
on:click={onSave}
|
||||
disabled={saveBusy}
|
||||
aria-label="Save now"
|
||||
title="Save now"
|
||||
>
|
||||
<span class="material-symbols-outlined" aria-hidden="true">save</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.editor-toolbar {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 12px;
|
||||
background: color-mix(in srgb, var(--surface-1) 90%, var(--zinc-700) 10%);
|
||||
box-shadow:
|
||||
inset 0 1px 0 color-mix(in srgb, var(--zinc-300) 9%, transparent 91%),
|
||||
0 8px 24px color-mix(in srgb, var(--bg-app) 38%, transparent 62%);
|
||||
padding: 8px 10px;
|
||||
margin: 0 14px;
|
||||
}
|
||||
|
||||
.toolbar-group {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-select-wrap {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: color-mix(in srgb, var(--surface-2) 92%, var(--zinc-700) 8%);
|
||||
color: color-mix(in srgb, var(--text-muted) 86%, var(--text-primary) 14%);
|
||||
padding: 0 8px;
|
||||
min-height: 30px;
|
||||
}
|
||||
|
||||
.toolbar-select-wrap .material-symbols-outlined {
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
width: 1px;
|
||||
height: 22px;
|
||||
background: color-mix(in srgb, var(--border-soft) 78%, transparent 22%);
|
||||
}
|
||||
|
||||
.toolbar-btn {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: color-mix(in srgb, var(--surface-2) 92%, var(--zinc-700) 8%);
|
||||
color: color-mix(in srgb, var(--text-muted) 86%, var(--text-primary) 14%);
|
||||
padding: 6px 10px;
|
||||
min-height: 30px;
|
||||
font-size: 0.73rem;
|
||||
letter-spacing: 0.01em;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease,
|
||||
border-color 120ms ease,
|
||||
transform 120ms ease;
|
||||
}
|
||||
|
||||
.toolbar-icon-btn {
|
||||
padding: 4px 6px;
|
||||
width: 32px;
|
||||
min-width: 32px;
|
||||
justify-content: center;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.toolbar-icon-btn .material-symbols-outlined {
|
||||
font-size: 17px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.heading-wrap {
|
||||
padding-right: 6px;
|
||||
gap: 3px;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.heading-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
min-height: 18px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.heading-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
min-width: 68px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--surface-1) 94%, var(--bg-editor) 6%);
|
||||
padding: 4px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.template-wrap {
|
||||
position: relative;
|
||||
padding-right: 6px;
|
||||
gap: 3px;
|
||||
}
|
||||
|
||||
.template-trigger {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 18px;
|
||||
min-height: 18px;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.template-trigger:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.template-menu {
|
||||
position: absolute;
|
||||
top: calc(100% + 6px);
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
width: 100%;
|
||||
min-width: 132px;
|
||||
max-width: 260px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: color-mix(in srgb, var(--surface-1) 94%, var(--bg-editor) 6%);
|
||||
padding: 4px;
|
||||
display: grid;
|
||||
gap: 2px;
|
||||
z-index: 20;
|
||||
}
|
||||
|
||||
.template-empty {
|
||||
padding: 6px 8px;
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.template-option {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.template-trigger-text {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.template-option:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.heading-option {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
border-radius: 6px;
|
||||
padding: 6px 8px;
|
||||
font-size: 0.76rem;
|
||||
color: var(--text-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.heading-option:hover {
|
||||
background: var(--bg-hover);
|
||||
}
|
||||
|
||||
.toolbar-btn:hover,
|
||||
.toolbar-btn:focus-visible {
|
||||
background: color-mix(in srgb, var(--bg-hover) 88%, var(--surface-2) 12%);
|
||||
color: var(--text-primary);
|
||||
border-color: var(--border-strong);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
.toolbar-btn:focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.toolbar-btn.is-active {
|
||||
background: color-mix(in srgb, var(--bg-active) 84%, var(--surface-2) 16%);
|
||||
border-color: var(--border-strong);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.toolbar-select-wrap:hover,
|
||||
.toolbar-select-wrap:focus-within {
|
||||
background: color-mix(in srgb, var(--bg-hover) 88%, var(--surface-2) 12%);
|
||||
border-color: var(--border-strong);
|
||||
}
|
||||
|
||||
.toolbar-btn:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: default;
|
||||
transform: none;
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.editor-toolbar {
|
||||
margin: 0 8px;
|
||||
padding: 7px 8px;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.toolbar-divider {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@ -1,3 +1,4 @@
|
||||
<!-- @format -->
|
||||
<script lang="ts">
|
||||
import {
|
||||
addTodoItem,
|
||||
@ -12,7 +13,7 @@
|
||||
todosStore,
|
||||
type TodoItem,
|
||||
updateTodoItemText,
|
||||
updateTodoItemTextBackend
|
||||
updateTodoItemTextBackend,
|
||||
} from "$lib/stores/todos";
|
||||
import { get } from "svelte/store";
|
||||
|
||||
@ -45,7 +46,9 @@
|
||||
if (!ok) {
|
||||
todoItems = toggleTodoItem(todoItems, id);
|
||||
} else {
|
||||
todoItems = todoItems.map((t) => (t.id === id ? { ...t, done: !t.done } : t));
|
||||
todoItems = todoItems.map((t) =>
|
||||
t.id === id ? { ...t, done: !t.done } : t,
|
||||
);
|
||||
}
|
||||
persistTodosForCurrentList();
|
||||
}
|
||||
@ -68,7 +71,9 @@
|
||||
if (!ok) {
|
||||
todoItems = updateTodoItemText(todoItems, id, text);
|
||||
} else {
|
||||
todoItems = todoItems.map((t) => (t.id === id ? { ...t, text: text.trim() } : t));
|
||||
todoItems = todoItems.map((t) =>
|
||||
t.id === id ? { ...t, text: text.trim() } : t,
|
||||
);
|
||||
}
|
||||
persistTodosForCurrentList();
|
||||
}
|
||||
@ -138,7 +143,11 @@
|
||||
{#each todoItems as todo}
|
||||
<li class="todo-item">
|
||||
<label class="todo-check">
|
||||
<input type="checkbox" checked={todo.done} on:change={() => toggleTodoDone(todo.id)} />
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={todo.done}
|
||||
on:change={() => toggleTodoDone(todo.id)}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{#if editingTodoId === todo.id}
|
||||
@ -152,14 +161,30 @@
|
||||
}}
|
||||
/>
|
||||
<div class="todo-actions">
|
||||
<button type="button" class="todo-btn save" on:click={saveEditTodo}>Save</button>
|
||||
<button type="button" class="todo-btn ghost" on:click={cancelEditTodo}>Cancel</button>
|
||||
<button
|
||||
type="button"
|
||||
class="todo-btn save"
|
||||
on:click={saveEditTodo}>Save</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="todo-btn ghost"
|
||||
on:click={cancelEditTodo}>Cancel</button
|
||||
>
|
||||
</div>
|
||||
{:else}
|
||||
<span class="todo-text" class:is-done={todo.done}>{todo.text}</span>
|
||||
<div class="todo-actions">
|
||||
<button type="button" class="todo-btn ghost" on:click={() => startEditTodo(todo.id)}>Edit</button>
|
||||
<button type="button" class="todo-btn danger" on:click={() => removeTodo(todo.id)}>Remove</button>
|
||||
<button
|
||||
type="button"
|
||||
class="todo-btn ghost"
|
||||
on:click={() => startEditTodo(todo.id)}>Edit</button
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
class="todo-btn danger"
|
||||
on:click={() => removeTodo(todo.id)}>Remove</button
|
||||
>
|
||||
</div>
|
||||
{/if}
|
||||
</li>
|
||||
@ -172,23 +197,22 @@
|
||||
.todo-surface {
|
||||
min-height: 0;
|
||||
flex: 1;
|
||||
padding: 8px;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
overflow: auto;
|
||||
padding: 0 14px 14px;
|
||||
}
|
||||
|
||||
.todo-card {
|
||||
width: min(760px, 100%);
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 12px;
|
||||
background: var(--surface-1);
|
||||
box-shadow: 0 14px 34px rgba(0, 0, 0, 0.25);
|
||||
padding: 14px;
|
||||
width: min(100%, 920px);
|
||||
margin: 0 auto;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
padding: 28px 36px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
gap: 12px;
|
||||
max-height: 100%;
|
||||
overflow: auto;
|
||||
overflow: visible;
|
||||
}
|
||||
|
||||
.todo-create {
|
||||
@ -201,19 +225,20 @@
|
||||
width: 100%;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
background: var(--bg-app);
|
||||
background: color-mix(in srgb, var(--surface-1) 88%, var(--bg-editor) 12%);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 10px;
|
||||
font-size: 0.86rem;
|
||||
padding: 10px 11px;
|
||||
font-size: 0.88rem;
|
||||
}
|
||||
|
||||
.todo-add-btn {
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
|
||||
color: var(--text-primary);
|
||||
padding: 8px 12px;
|
||||
padding: 9px 14px;
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@ -225,7 +250,7 @@
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
@ -235,8 +260,8 @@
|
||||
gap: 10px;
|
||||
border: 1px solid var(--border-soft);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
background: var(--bg-app);
|
||||
padding: 10px 12px;
|
||||
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
|
||||
}
|
||||
|
||||
.todo-check {
|
||||
@ -245,8 +270,9 @@
|
||||
}
|
||||
|
||||
.todo-text {
|
||||
font-size: 0.86rem;
|
||||
font-size: 0.9rem;
|
||||
color: var(--text-primary);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.todo-text.is-done {
|
||||
@ -262,7 +288,7 @@
|
||||
.todo-btn {
|
||||
border-radius: 7px;
|
||||
border: 1px solid var(--border-soft);
|
||||
background: var(--surface-1);
|
||||
background: color-mix(in srgb, var(--surface-1) 90%, var(--bg-editor) 10%);
|
||||
color: var(--text-muted);
|
||||
padding: 6px 10px;
|
||||
font-size: 0.78rem;
|
||||
@ -271,7 +297,7 @@
|
||||
|
||||
.todo-btn.save {
|
||||
border-color: var(--border-strong);
|
||||
background: var(--surface-3);
|
||||
background: color-mix(in srgb, var(--surface-2) 84%, var(--bg-hover) 16%);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@ -281,4 +307,33 @@
|
||||
background: var(--bg-hover);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
@media (max-width: 980px) {
|
||||
.todo-surface {
|
||||
padding: 4px 8px 10px;
|
||||
}
|
||||
|
||||
.todo-card {
|
||||
width: 100%;
|
||||
padding: 18px 16px;
|
||||
}
|
||||
|
||||
.todo-create {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.todo-add-btn {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.todo-item {
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
row-gap: 8px;
|
||||
}
|
||||
|
||||
.todo-actions {
|
||||
grid-column: 1 / -1;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -9,6 +9,7 @@ type WindowWithTauri = Window & {
|
||||
type UiSettingsPayload = {
|
||||
tags?: string[];
|
||||
fragmentTypes?: string[];
|
||||
defaultStartupView?: string;
|
||||
};
|
||||
|
||||
type FetchJsonOptions = {
|
||||
@ -31,7 +32,10 @@ export function isTauriRuntime(): boolean {
|
||||
return false;
|
||||
}
|
||||
|
||||
return Object.prototype.hasOwnProperty.call(window as WindowWithTauri, "__TAURI_INTERNALS__");
|
||||
return Object.prototype.hasOwnProperty.call(
|
||||
window as WindowWithTauri,
|
||||
"__TAURI_INTERNALS__",
|
||||
);
|
||||
}
|
||||
|
||||
function readUiSettingsFromLocalStorage(): UiSettingsPayload {
|
||||
@ -48,7 +52,13 @@ function readUiSettingsFromLocalStorage(): UiSettingsPayload {
|
||||
const parsed = JSON.parse(raw) as UiSettingsPayload;
|
||||
return {
|
||||
tags: Array.isArray(parsed.tags) ? parsed.tags : undefined,
|
||||
fragmentTypes: Array.isArray(parsed.fragmentTypes) ? parsed.fragmentTypes : undefined
|
||||
fragmentTypes: Array.isArray(parsed.fragmentTypes)
|
||||
? parsed.fragmentTypes
|
||||
: undefined,
|
||||
defaultStartupView:
|
||||
typeof parsed.defaultStartupView === "string"
|
||||
? parsed.defaultStartupView
|
||||
: undefined,
|
||||
};
|
||||
} catch {
|
||||
return {};
|
||||
@ -62,20 +72,31 @@ function writeUiSettingsToLocalStorage(payload: UiSettingsPayload): void {
|
||||
|
||||
const safePayload: UiSettingsPayload = {
|
||||
tags: Array.isArray(payload.tags) ? payload.tags : undefined,
|
||||
fragmentTypes: Array.isArray(payload.fragmentTypes) ? payload.fragmentTypes : undefined
|
||||
fragmentTypes: Array.isArray(payload.fragmentTypes)
|
||||
? payload.fragmentTypes
|
||||
: undefined,
|
||||
defaultStartupView:
|
||||
typeof payload.defaultStartupView === "string"
|
||||
? payload.defaultStartupView
|
||||
: undefined,
|
||||
};
|
||||
|
||||
window.localStorage.setItem(UI_SETTINGS_KEY, JSON.stringify(safePayload));
|
||||
}
|
||||
|
||||
async function fetchJson<T>(path: string, init: RequestInit = {}, options: FetchJsonOptions = {}): Promise<T> {
|
||||
const response = await fetch(`${normalizedApiBase()}${path}`, {
|
||||
async function fetchJson<T>(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
options: FetchJsonOptions = {},
|
||||
apiBase?: string,
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${apiBase ?? normalizedApiBase()}${path}`, {
|
||||
...init,
|
||||
keepalive: options.keepalive === true,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init.headers ?? {})
|
||||
}
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
@ -90,7 +111,10 @@ async function fetchJson<T>(path: string, init: RequestInit = {}, options: Fetch
|
||||
return (await response.json()) as T;
|
||||
}
|
||||
|
||||
export async function invoke<T>(command: string, args?: InvokeArgs): Promise<T> {
|
||||
export async function invoke<T>(
|
||||
command: string,
|
||||
args?: InvokeArgs,
|
||||
): Promise<T> {
|
||||
if (isTauriRuntime()) {
|
||||
const tauriCore = await import("@tauri-apps/api/core");
|
||||
return tauriCore.invoke<T>(command, args);
|
||||
@ -109,9 +133,9 @@ export async function invoke<T>(command: string, args?: InvokeArgs): Promise<T>
|
||||
"/command",
|
||||
{
|
||||
method: "POST",
|
||||
body: JSON.stringify(envelope as BackendCommand)
|
||||
body: JSON.stringify(envelope as BackendCommand),
|
||||
},
|
||||
{ keepalive }
|
||||
{ keepalive },
|
||||
);
|
||||
}
|
||||
case "get_sidecar_root":
|
||||
@ -120,23 +144,41 @@ export async function invoke<T>(command: string, args?: InvokeArgs): Promise<T>
|
||||
const path = typeof args?.path === "string" ? args.path : "";
|
||||
return fetchJson<T>("/sidecar/root", {
|
||||
method: "POST",
|
||||
body: JSON.stringify({ path })
|
||||
body: JSON.stringify({ path }),
|
||||
});
|
||||
}
|
||||
case "get_ui_settings":
|
||||
return readUiSettingsFromLocalStorage() as T;
|
||||
case "set_ui_settings": {
|
||||
const tags = Array.isArray(args?.tags) ? (args?.tags as string[]) : undefined;
|
||||
const fragmentTypes =
|
||||
Array.isArray(args?.fragmentTypes) ? (args?.fragmentTypes as string[]) :
|
||||
Array.isArray(args?.fragment_types) ? (args?.fragment_types as string[]) :
|
||||
undefined;
|
||||
const tags = Array.isArray(args?.tags)
|
||||
? (args?.tags as string[])
|
||||
: undefined;
|
||||
const fragmentTypes = Array.isArray(args?.fragmentTypes)
|
||||
? (args?.fragmentTypes as string[])
|
||||
: Array.isArray(args?.fragment_types)
|
||||
? (args?.fragment_types as string[])
|
||||
: undefined;
|
||||
const defaultStartupView =
|
||||
typeof args?.defaultStartupView === "string"
|
||||
? args.defaultStartupView
|
||||
: typeof args?.default_startup_view === "string"
|
||||
? args.default_startup_view
|
||||
: undefined;
|
||||
|
||||
writeUiSettingsToLocalStorage({ tags, fragmentTypes });
|
||||
writeUiSettingsToLocalStorage({
|
||||
tags,
|
||||
fragmentTypes,
|
||||
defaultStartupView,
|
||||
});
|
||||
return undefined as T;
|
||||
}
|
||||
case "shutdown":
|
||||
return undefined as T;
|
||||
case "speech_start":
|
||||
case "speech_stop":
|
||||
throw new Error(
|
||||
"Speech dictation is available in the desktop app runtime only.",
|
||||
);
|
||||
default:
|
||||
throw new Error(`Unsupported command in web runtime: ${command}`);
|
||||
}
|
||||
|
||||
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";
|
||||
|
||||
//#region 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[];
|
||||
};
|
||||
//#endregion
|
||||
|
||||
//#region 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: [],
|
||||
});
|
||||
//#endregion
|
||||
|
||||
//#region 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: [] });
|
||||
}
|
||||
//#endregion
|
||||
213
Journal.App/src/lib/stores/conversations.ts
Normal file
@ -0,0 +1,213 @@
|
||||
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";
|
||||
|
||||
//#region Store Shapes
|
||||
type ConversationsState = {
|
||||
items: ConversationDto[];
|
||||
busy: boolean;
|
||||
error: string;
|
||||
};
|
||||
|
||||
type ActiveConversationState = {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: ConversationMessageDto[];
|
||||
busy: boolean;
|
||||
error: string;
|
||||
};
|
||||
//#endregion
|
||||
|
||||
//#region Stores
|
||||
export const conversationsStore = writable<ConversationsState>({
|
||||
items: [],
|
||||
busy: false,
|
||||
error: "",
|
||||
});
|
||||
|
||||
export const activeConversationStore = writable<ActiveConversationState>({
|
||||
id: "",
|
||||
title: "",
|
||||
messages: [],
|
||||
busy: false,
|
||||
error: "",
|
||||
});
|
||||
//#endregion
|
||||
|
||||
//#region 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],
|
||||
}));
|
||||
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) {
|
||||
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;
|
||||
|
||||
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,
|
||||
messages: [
|
||||
...s.messages.filter((m) => m.id !== tempUserMsg.id),
|
||||
result.userMessage,
|
||||
result.assistantMessage,
|
||||
],
|
||||
}));
|
||||
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),
|
||||
}));
|
||||
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: "",
|
||||
});
|
||||
}
|
||||
//#endregion
|
||||