generated from nhcarrigan/template
Compare commits
4 Commits
43f92f227c
...
73d66e9ae4
| Author | SHA1 | Date | |
|---|---|---|---|
| 73d66e9ae4 | |||
| 42c3b4ee83 | |||
| 8a19f35922 | |||
| 4f02747064 |
@@ -20,6 +20,14 @@
|
|||||||
"fs:default",
|
"fs:default",
|
||||||
"fs:allow-read-text-file",
|
"fs:allow-read-text-file",
|
||||||
"fs:allow-write-text-file",
|
"fs:allow-write-text-file",
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-read-file",
|
||||||
|
"allow": [{ "path": "**" }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"identifier": "fs:allow-write-file",
|
||||||
|
"allow": [{ "path": "**" }]
|
||||||
|
},
|
||||||
"core:window:allow-set-size",
|
"core:window:allow-set-size",
|
||||||
"core:window:allow-set-always-on-top",
|
"core:window:allow-set-always-on-top",
|
||||||
"core:window:allow-inner-size"
|
"core:window:allow-inner-size"
|
||||||
|
|||||||
+1485
-2
File diff suppressed because it is too large
Load Diff
@@ -102,6 +102,19 @@ pub async fn get_usage_stats(
|
|||||||
manager.get_usage_stats(&conversation_id)
|
manager.get_usage_stats(&conversation_id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Load persisted lifetime stats from store (no bridge required)
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn get_persisted_stats(app: AppHandle) -> Result<UsageStats, String> {
|
||||||
|
let mut stats = UsageStats::new();
|
||||||
|
|
||||||
|
// Load persisted stats if available
|
||||||
|
if let Some(persisted) = crate::stats::load_stats(&app).await {
|
||||||
|
stats.apply_persisted(persisted);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(stats)
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn validate_directory(
|
pub async fn validate_directory(
|
||||||
path: String,
|
path: String,
|
||||||
|
|||||||
@@ -82,6 +82,16 @@ pub struct HikariConfig {
|
|||||||
|
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
pub compact_mode: bool,
|
pub compact_mode: bool,
|
||||||
|
|
||||||
|
// Profile fields
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile_name: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile_avatar_path: Option<String>,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub profile_bio: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for HikariConfig {
|
impl Default for HikariConfig {
|
||||||
@@ -105,6 +115,9 @@ impl Default for HikariConfig {
|
|||||||
streamer_mode: false,
|
streamer_mode: false,
|
||||||
streamer_hide_paths: false,
|
streamer_hide_paths: false,
|
||||||
compact_mode: false,
|
compact_mode: false,
|
||||||
|
profile_name: None,
|
||||||
|
profile_avatar_path: None,
|
||||||
|
profile_bio: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -162,6 +175,9 @@ mod tests {
|
|||||||
assert!(!config.streamer_mode);
|
assert!(!config.streamer_mode);
|
||||||
assert!(!config.streamer_hide_paths);
|
assert!(!config.streamer_hide_paths);
|
||||||
assert!(!config.compact_mode);
|
assert!(!config.compact_mode);
|
||||||
|
assert!(config.profile_name.is_none());
|
||||||
|
assert!(config.profile_avatar_path.is_none());
|
||||||
|
assert!(config.profile_bio.is_none());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
@@ -185,6 +201,9 @@ mod tests {
|
|||||||
streamer_mode: false,
|
streamer_mode: false,
|
||||||
streamer_hide_paths: false,
|
streamer_hide_paths: false,
|
||||||
compact_mode: false,
|
compact_mode: false,
|
||||||
|
profile_name: Some("Test User".to_string()),
|
||||||
|
profile_avatar_path: None,
|
||||||
|
profile_bio: Some("A test bio".to_string()),
|
||||||
};
|
};
|
||||||
|
|
||||||
let json = serde_json::to_string(&config).unwrap();
|
let json = serde_json::to_string(&config).unwrap();
|
||||||
|
|||||||
@@ -95,6 +95,7 @@ pub fn run() {
|
|||||||
get_config,
|
get_config,
|
||||||
save_config,
|
save_config,
|
||||||
get_usage_stats,
|
get_usage_stats,
|
||||||
|
get_persisted_stats,
|
||||||
load_saved_achievements,
|
load_saved_achievements,
|
||||||
answer_question,
|
answer_question,
|
||||||
send_windows_notification,
|
send_windows_notification,
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
use crate::achievements::{check_achievements, AchievementProgress};
|
use crate::achievements::{check_achievements, AchievementProgress};
|
||||||
|
use chrono::{Local, Timelike};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::time::Instant;
|
use std::time::Instant;
|
||||||
|
use tauri_plugin_store::StoreExt;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
pub struct UsageStats {
|
pub struct UsageStats {
|
||||||
@@ -28,6 +30,14 @@ pub struct UsageStats {
|
|||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub session_start: Option<Instant>,
|
pub session_start: Option<Instant>,
|
||||||
|
|
||||||
|
// Extended tracking for achievements
|
||||||
|
pub sessions_started: u64,
|
||||||
|
pub consecutive_days: u64,
|
||||||
|
pub total_days_used: u64,
|
||||||
|
pub morning_sessions: u64, // Sessions started before 9 AM
|
||||||
|
pub night_sessions: u64, // Sessions started after 10 PM
|
||||||
|
pub last_session_date: Option<String>, // ISO date string for streak tracking
|
||||||
|
|
||||||
// Achievement tracking
|
// Achievement tracking
|
||||||
#[serde(skip)]
|
#[serde(skip)]
|
||||||
pub achievements: AchievementProgress,
|
pub achievements: AchievementProgress,
|
||||||
@@ -65,6 +75,47 @@ impl UsageStats {
|
|||||||
self.session_duration_seconds = 0;
|
self.session_duration_seconds = 0;
|
||||||
self.session_start = Some(Instant::now());
|
self.session_start = Some(Instant::now());
|
||||||
self.achievements.start_session();
|
self.achievements.start_session();
|
||||||
|
|
||||||
|
// Track session start for achievements
|
||||||
|
self.track_session_start();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn track_session_start(&mut self) {
|
||||||
|
let now = Local::now();
|
||||||
|
let today = now.format("%Y-%m-%d").to_string();
|
||||||
|
let hour = now.hour();
|
||||||
|
|
||||||
|
// Increment session count
|
||||||
|
self.sessions_started += 1;
|
||||||
|
|
||||||
|
// Track morning/night sessions
|
||||||
|
if hour < 9 {
|
||||||
|
self.morning_sessions += 1;
|
||||||
|
}
|
||||||
|
if hour >= 22 {
|
||||||
|
self.night_sessions += 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track consecutive days and total days
|
||||||
|
if let Some(last_date) = &self.last_session_date {
|
||||||
|
if last_date != &today {
|
||||||
|
// Check if it's the next day (consecutive)
|
||||||
|
if is_consecutive_day(last_date, &today) {
|
||||||
|
self.consecutive_days += 1;
|
||||||
|
} else {
|
||||||
|
// Streak broken
|
||||||
|
self.consecutive_days = 1;
|
||||||
|
}
|
||||||
|
self.total_days_used += 1;
|
||||||
|
self.last_session_date = Some(today);
|
||||||
|
}
|
||||||
|
// Same day - don't increment anything
|
||||||
|
} else {
|
||||||
|
// First session ever
|
||||||
|
self.consecutive_days = 1;
|
||||||
|
self.total_days_used = 1;
|
||||||
|
self.last_session_date = Some(today);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn increment_messages(&mut self) {
|
pub fn increment_messages(&mut self) {
|
||||||
@@ -127,12 +178,34 @@ impl UsageStats {
|
|||||||
session_tools_usage: self.session_tools_usage.clone(),
|
session_tools_usage: self.session_tools_usage.clone(),
|
||||||
session_duration_seconds: self.session_duration_seconds,
|
session_duration_seconds: self.session_duration_seconds,
|
||||||
session_start: self.session_start,
|
session_start: self.session_start,
|
||||||
|
sessions_started: self.sessions_started,
|
||||||
|
consecutive_days: self.consecutive_days,
|
||||||
|
total_days_used: self.total_days_used,
|
||||||
|
morning_sessions: self.morning_sessions,
|
||||||
|
night_sessions: self.night_sessions,
|
||||||
|
last_session_date: self.last_session_date.clone(),
|
||||||
achievements: AchievementProgress::new(), // Dummy for copy
|
achievements: AchievementProgress::new(), // Dummy for copy
|
||||||
};
|
};
|
||||||
check_achievements(&stats_copy, &mut self.achievements)
|
check_achievements(&stats_copy, &mut self.achievements)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper function to check if two dates are consecutive
|
||||||
|
fn is_consecutive_day(prev_date: &str, current_date: &str) -> bool {
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
|
||||||
|
let prev = NaiveDate::parse_from_str(prev_date, "%Y-%m-%d").ok();
|
||||||
|
let current = NaiveDate::parse_from_str(current_date, "%Y-%m-%d").ok();
|
||||||
|
|
||||||
|
match (prev, current) {
|
||||||
|
(Some(p), Some(c)) => {
|
||||||
|
let diff = c.signed_duration_since(p).num_days();
|
||||||
|
diff == 1
|
||||||
|
}
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Pricing as of January 2025
|
// Pricing as of January 2025
|
||||||
// https://www.anthropic.com/pricing
|
// https://www.anthropic.com/pricing
|
||||||
fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
|
fn calculate_cost(input_tokens: u64, output_tokens: u64, model: &str) -> f64 {
|
||||||
@@ -169,6 +242,111 @@ pub struct StatsUpdateEvent {
|
|||||||
pub stats: UsageStats,
|
pub stats: UsageStats,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Serializable struct for persisting only lifetime (total) stats
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
|
||||||
|
pub struct PersistedStats {
|
||||||
|
pub total_input_tokens: u64,
|
||||||
|
pub total_output_tokens: u64,
|
||||||
|
pub total_cost_usd: f64,
|
||||||
|
pub messages_exchanged: u64,
|
||||||
|
pub code_blocks_generated: u64,
|
||||||
|
pub files_edited: u64,
|
||||||
|
pub files_created: u64,
|
||||||
|
pub tools_usage: HashMap<String, u64>,
|
||||||
|
pub sessions_started: u64,
|
||||||
|
pub consecutive_days: u64,
|
||||||
|
pub total_days_used: u64,
|
||||||
|
pub morning_sessions: u64,
|
||||||
|
pub night_sessions: u64,
|
||||||
|
pub last_session_date: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl From<&UsageStats> for PersistedStats {
|
||||||
|
fn from(stats: &UsageStats) -> Self {
|
||||||
|
PersistedStats {
|
||||||
|
total_input_tokens: stats.total_input_tokens,
|
||||||
|
total_output_tokens: stats.total_output_tokens,
|
||||||
|
total_cost_usd: stats.total_cost_usd,
|
||||||
|
messages_exchanged: stats.messages_exchanged,
|
||||||
|
code_blocks_generated: stats.code_blocks_generated,
|
||||||
|
files_edited: stats.files_edited,
|
||||||
|
files_created: stats.files_created,
|
||||||
|
tools_usage: stats.tools_usage.clone(),
|
||||||
|
sessions_started: stats.sessions_started,
|
||||||
|
consecutive_days: stats.consecutive_days,
|
||||||
|
total_days_used: stats.total_days_used,
|
||||||
|
morning_sessions: stats.morning_sessions,
|
||||||
|
night_sessions: stats.night_sessions,
|
||||||
|
last_session_date: stats.last_session_date.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UsageStats {
|
||||||
|
/// Apply persisted stats to restore lifetime totals
|
||||||
|
pub fn apply_persisted(&mut self, persisted: PersistedStats) {
|
||||||
|
self.total_input_tokens = persisted.total_input_tokens;
|
||||||
|
self.total_output_tokens = persisted.total_output_tokens;
|
||||||
|
self.total_cost_usd = persisted.total_cost_usd;
|
||||||
|
self.messages_exchanged = persisted.messages_exchanged;
|
||||||
|
self.code_blocks_generated = persisted.code_blocks_generated;
|
||||||
|
self.files_edited = persisted.files_edited;
|
||||||
|
self.files_created = persisted.files_created;
|
||||||
|
self.tools_usage = persisted.tools_usage;
|
||||||
|
self.sessions_started = persisted.sessions_started;
|
||||||
|
self.consecutive_days = persisted.consecutive_days;
|
||||||
|
self.total_days_used = persisted.total_days_used;
|
||||||
|
self.morning_sessions = persisted.morning_sessions;
|
||||||
|
self.night_sessions = persisted.night_sessions;
|
||||||
|
self.last_session_date = persisted.last_session_date;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Save lifetime stats to persistent store
|
||||||
|
pub async fn save_stats(app: &tauri::AppHandle, stats: &UsageStats) -> Result<(), String> {
|
||||||
|
let store = app.store("stats.json").map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
let persisted = PersistedStats::from(stats);
|
||||||
|
|
||||||
|
println!("Saving stats: {:?}", persisted);
|
||||||
|
|
||||||
|
store.set(
|
||||||
|
"lifetime_stats",
|
||||||
|
serde_json::to_value(persisted).map_err(|e| e.to_string())?,
|
||||||
|
);
|
||||||
|
store.save().map_err(|e| e.to_string())?;
|
||||||
|
|
||||||
|
println!("Stats saved successfully");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Load lifetime stats from persistent store
|
||||||
|
pub async fn load_stats(app: &tauri::AppHandle) -> Option<PersistedStats> {
|
||||||
|
println!("Loading stats from store...");
|
||||||
|
|
||||||
|
let store = match app.store("stats.json") {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(e) => {
|
||||||
|
println!("Failed to open stats store: {}", e);
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(stats_value) = store.get("lifetime_stats") {
|
||||||
|
println!("Found lifetime stats in store: {:?}", stats_value);
|
||||||
|
if let Ok(persisted) = serde_json::from_value::<PersistedStats>(stats_value.clone()) {
|
||||||
|
println!("Loaded lifetime stats successfully");
|
||||||
|
return Some(persisted);
|
||||||
|
} else {
|
||||||
|
println!("Failed to parse lifetime stats");
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
println!("No lifetime stats found in store");
|
||||||
|
}
|
||||||
|
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|||||||
@@ -112,7 +112,7 @@ impl WslBridge {
|
|||||||
return Err("Process already running".to_string());
|
return Err("Process already running".to_string());
|
||||||
}
|
}
|
||||||
|
|
||||||
// Load saved achievements when starting a new session
|
// Load saved achievements and stats when starting a new session
|
||||||
let app_clone = app.clone();
|
let app_clone = app.clone();
|
||||||
let stats = self.stats.clone();
|
let stats = self.stats.clone();
|
||||||
tauri::async_runtime::spawn(async move {
|
tauri::async_runtime::spawn(async move {
|
||||||
@@ -122,7 +122,17 @@ impl WslBridge {
|
|||||||
"Loaded {} unlocked achievements",
|
"Loaded {} unlocked achievements",
|
||||||
achievements.unlocked.len()
|
achievements.unlocked.len()
|
||||||
);
|
);
|
||||||
stats.write().achievements = achievements;
|
|
||||||
|
println!("Loading saved stats...");
|
||||||
|
let persisted_stats = crate::stats::load_stats(&app_clone).await;
|
||||||
|
|
||||||
|
let mut stats_guard = stats.write();
|
||||||
|
stats_guard.achievements = achievements;
|
||||||
|
|
||||||
|
if let Some(persisted) = persisted_stats {
|
||||||
|
println!("Applying persisted lifetime stats");
|
||||||
|
stats_guard.apply_persisted(persisted);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
let working_dir = &options.working_dir;
|
let working_dir = &options.working_dir;
|
||||||
@@ -440,6 +450,18 @@ impl WslBridge {
|
|||||||
self.session_id = None;
|
self.session_id = None;
|
||||||
self.mcp_config_file = None; // Temp file is automatically deleted when dropped
|
self.mcp_config_file = None; // Temp file is automatically deleted when dropped
|
||||||
|
|
||||||
|
// Save lifetime stats before resetting session
|
||||||
|
let stats_snapshot = self.stats.read().clone();
|
||||||
|
let app_clone = app.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
println!("Saving stats on session stop...");
|
||||||
|
if let Err(e) = crate::stats::save_stats(&app_clone, &stats_snapshot).await {
|
||||||
|
eprintln!("Failed to save stats: {}", e);
|
||||||
|
} else {
|
||||||
|
println!("Stats saved successfully on session stop");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
// Reset session stats on explicit disconnect
|
// Reset session stats on explicit disconnect
|
||||||
self.stats.write().reset_session();
|
self.stats.write().reset_session();
|
||||||
|
|
||||||
@@ -733,10 +755,23 @@ fn process_json_line(
|
|||||||
|
|
||||||
let current_stats = stats.read().clone();
|
let current_stats = stats.read().clone();
|
||||||
let stats_event = StatsUpdateEvent {
|
let stats_event = StatsUpdateEvent {
|
||||||
stats: current_stats,
|
stats: current_stats.clone(),
|
||||||
};
|
};
|
||||||
let _ = app.emit("claude:stats", stats_event);
|
let _ = app.emit("claude:stats", stats_event);
|
||||||
|
|
||||||
|
// Save stats periodically (every 10 messages to avoid excessive disk writes)
|
||||||
|
if current_stats.session_messages_exchanged.is_multiple_of(10)
|
||||||
|
&& current_stats.session_messages_exchanged > 0
|
||||||
|
{
|
||||||
|
let app_handle = app.clone();
|
||||||
|
tauri::async_runtime::spawn(async move {
|
||||||
|
println!("Periodic stats save (every 10 messages)...");
|
||||||
|
if let Err(e) = crate::stats::save_stats(&app_handle, ¤t_stats).await {
|
||||||
|
eprintln!("Failed to save stats: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// Only emit error results - success content is already sent via Assistant message
|
// Only emit error results - success content is already sent via Assistant message
|
||||||
if subtype != "success" {
|
if subtype != "success" {
|
||||||
if let Some(text) = result {
|
if let Some(text) = result {
|
||||||
|
|||||||
@@ -30,6 +30,9 @@
|
|||||||
streamer_mode: false,
|
streamer_mode: false,
|
||||||
streamer_hide_paths: false,
|
streamer_hide_paths: false,
|
||||||
compact_mode: false,
|
compact_mode: false,
|
||||||
|
profile_name: null,
|
||||||
|
profile_avatar_path: null,
|
||||||
|
profile_bio: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
let isOpen = $state(false);
|
let isOpen = $state(false);
|
||||||
|
|||||||
@@ -0,0 +1,833 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { configStore, type HikariConfig } from "$lib/stores/config";
|
||||||
|
import { formattedStats } from "$lib/stores/stats";
|
||||||
|
import { achievementsStore } from "$lib/stores/achievements";
|
||||||
|
import { open, save } from "@tauri-apps/plugin-dialog";
|
||||||
|
import { writeFile, readFile } from "@tauri-apps/plugin-fs";
|
||||||
|
|
||||||
|
export let onClose: () => void;
|
||||||
|
|
||||||
|
let config: HikariConfig;
|
||||||
|
configStore.config.subscribe((c) => (config = c));
|
||||||
|
|
||||||
|
let editingName = false;
|
||||||
|
let editingBio = false;
|
||||||
|
let nameInput = "";
|
||||||
|
let bioInput = "";
|
||||||
|
let avatarDataUrl: string | null = null;
|
||||||
|
|
||||||
|
// Initialize inputs when config is loaded
|
||||||
|
$: if (config) {
|
||||||
|
if (!editingName) nameInput = config.profile_name || "";
|
||||||
|
if (!editingBio) bioInput = config.profile_bio || "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load avatar on mount and when path changes
|
||||||
|
let lastLoadedPath: string | null = null;
|
||||||
|
|
||||||
|
async function updateAvatarDisplay(path: string | null) {
|
||||||
|
if (path === lastLoadedPath) return;
|
||||||
|
lastLoadedPath = path;
|
||||||
|
if (path) {
|
||||||
|
avatarDataUrl = await loadAvatarAsDataUrl(path);
|
||||||
|
} else {
|
||||||
|
avatarDataUrl = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$: updateAvatarDisplay(config?.profile_avatar_path ?? null);
|
||||||
|
|
||||||
|
let isGeneratingImage = false;
|
||||||
|
|
||||||
|
$: unlockedCount = Object.values($achievementsStore.achievements).filter((a) => a.unlocked).length;
|
||||||
|
$: totalAchievements = Object.values($achievementsStore.achievements).length;
|
||||||
|
$: achievementPercentage = totalAchievements > 0
|
||||||
|
? Math.round((unlockedCount / totalAchievements) * 100)
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
async function selectAvatar() {
|
||||||
|
try {
|
||||||
|
const selected = await open({
|
||||||
|
multiple: false,
|
||||||
|
filters: [
|
||||||
|
{
|
||||||
|
name: "Images",
|
||||||
|
extensions: ["png", "jpg", "jpeg", "gif", "webp"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
if (selected && typeof selected === "string") {
|
||||||
|
await configStore.updateConfig({ profile_avatar_path: selected });
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to select avatar:", error);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function removeAvatar() {
|
||||||
|
await configStore.updateConfig({ profile_avatar_path: null });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveName() {
|
||||||
|
await configStore.updateConfig({ profile_name: nameInput || null });
|
||||||
|
editingName = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveBio() {
|
||||||
|
await configStore.updateConfig({ profile_bio: bioInput || null });
|
||||||
|
editingBio = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadAvatarAsDataUrl(path: string): Promise<string | null> {
|
||||||
|
try {
|
||||||
|
const data = await readFile(path);
|
||||||
|
const extension = path.split('.').pop()?.toLowerCase() || 'png';
|
||||||
|
const mimeType = extension === 'jpg' || extension === 'jpeg' ? 'image/jpeg'
|
||||||
|
: extension === 'gif' ? 'image/gif'
|
||||||
|
: extension === 'webp' ? 'image/webp'
|
||||||
|
: 'image/png';
|
||||||
|
// Convert Uint8Array to base64 in chunks to avoid stack overflow
|
||||||
|
const blob = new Blob([data], { type: mimeType });
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
|
reader.onerror = () => resolve(null);
|
||||||
|
reader.readAsDataURL(blob);
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load avatar:", error);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateShareImage(): Promise<Blob> {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
|
||||||
|
// Card dimensions (1080p for sharing)
|
||||||
|
const width = 1920;
|
||||||
|
const height = 1080;
|
||||||
|
canvas.width = width;
|
||||||
|
canvas.height = height;
|
||||||
|
|
||||||
|
// Background gradient (dark theme)
|
||||||
|
const bgGradient = ctx.createLinearGradient(0, 0, width, height);
|
||||||
|
bgGradient.addColorStop(0, "#1a1a2e");
|
||||||
|
bgGradient.addColorStop(1, "#16213e");
|
||||||
|
ctx.fillStyle = bgGradient;
|
||||||
|
ctx.fillRect(0, 0, width, height);
|
||||||
|
|
||||||
|
// Trans flag stripe accent at top
|
||||||
|
const stripeHeight = 25;
|
||||||
|
const stripeColors = ["#5bcefa", "#f5a9b8", "#ffffff", "#f5a9b8", "#5bcefa"];
|
||||||
|
stripeColors.forEach((color, i) => {
|
||||||
|
ctx.fillStyle = color;
|
||||||
|
ctx.fillRect(0, i * (stripeHeight / 5) * 2, width, stripeHeight / 5 * 2);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Border
|
||||||
|
ctx.strokeStyle = "#5bcefa";
|
||||||
|
ctx.lineWidth = 6;
|
||||||
|
ctx.strokeRect(3, 3, width - 6, height - 6);
|
||||||
|
|
||||||
|
// Avatar circle
|
||||||
|
const avatarX = 200;
|
||||||
|
const avatarY = 220;
|
||||||
|
const avatarRadius = 140;
|
||||||
|
|
||||||
|
ctx.save();
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(avatarX, avatarY, avatarRadius, 0, Math.PI * 2);
|
||||||
|
ctx.closePath();
|
||||||
|
ctx.clip();
|
||||||
|
|
||||||
|
// Draw avatar if available, otherwise gradient placeholder
|
||||||
|
let avatarLoaded = false;
|
||||||
|
if (config.profile_avatar_path) {
|
||||||
|
try {
|
||||||
|
const dataUrl = await loadAvatarAsDataUrl(config.profile_avatar_path);
|
||||||
|
if (dataUrl) {
|
||||||
|
const img = new Image();
|
||||||
|
await new Promise<void>((resolve, reject) => {
|
||||||
|
img.onload = () => resolve();
|
||||||
|
img.onerror = () => reject();
|
||||||
|
img.src = dataUrl;
|
||||||
|
});
|
||||||
|
ctx.drawImage(img, avatarX - avatarRadius, avatarY - avatarRadius, avatarRadius * 2, avatarRadius * 2);
|
||||||
|
avatarLoaded = true;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Will use fallback gradient
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!avatarLoaded) {
|
||||||
|
const avatarGradient = ctx.createLinearGradient(avatarX - avatarRadius, avatarY - avatarRadius, avatarX + avatarRadius, avatarY + avatarRadius);
|
||||||
|
avatarGradient.addColorStop(0, "#5bcefa");
|
||||||
|
avatarGradient.addColorStop(0.5, "#f5a9b8");
|
||||||
|
avatarGradient.addColorStop(1, "#5bcefa");
|
||||||
|
ctx.fillStyle = avatarGradient;
|
||||||
|
ctx.fillRect(avatarX - avatarRadius, avatarY - avatarRadius, avatarRadius * 2, avatarRadius * 2);
|
||||||
|
}
|
||||||
|
ctx.restore();
|
||||||
|
|
||||||
|
// Avatar border
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.arc(avatarX, avatarY, avatarRadius + 6, 0, Math.PI * 2);
|
||||||
|
ctx.strokeStyle = "#f5a9b8";
|
||||||
|
ctx.lineWidth = 8;
|
||||||
|
ctx.stroke();
|
||||||
|
|
||||||
|
// Name
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.font = "bold 72px system-ui, -apple-system, sans-serif";
|
||||||
|
ctx.fillText(config.profile_name || "Hikari User", 400, 180);
|
||||||
|
|
||||||
|
// Bio (truncated)
|
||||||
|
ctx.fillStyle = "#9ca3af";
|
||||||
|
ctx.font = "36px system-ui, -apple-system, sans-serif";
|
||||||
|
const bio = config.profile_bio || "A Hikari Desktop user";
|
||||||
|
const truncatedBio = bio.length > 100 ? bio.substring(0, 97) + "..." : bio;
|
||||||
|
ctx.fillText(truncatedBio, 400, 260);
|
||||||
|
|
||||||
|
// Stats section
|
||||||
|
const statsY = 420;
|
||||||
|
ctx.fillStyle = "#6b7280";
|
||||||
|
ctx.font = "bold 32px system-ui, -apple-system, sans-serif";
|
||||||
|
ctx.fillText("LIFETIME STATS", 80, statsY);
|
||||||
|
|
||||||
|
// Stats grid
|
||||||
|
const stats = [
|
||||||
|
{ label: "Messages", value: $formattedStats.messagesTotal },
|
||||||
|
{ label: "Tokens", value: $formattedStats.totalTokens },
|
||||||
|
{ label: "Code Blocks", value: $formattedStats.codeBlocksTotal },
|
||||||
|
{ label: "Files Edited", value: $formattedStats.filesEditedTotal },
|
||||||
|
{ label: "Files Created", value: $formattedStats.filesCreatedTotal },
|
||||||
|
{ label: "Total Cost", value: $formattedStats.totalCost },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statBoxWidth = 540;
|
||||||
|
const statBoxHeight = 160;
|
||||||
|
const statsPerRow = 3;
|
||||||
|
const startX = 80;
|
||||||
|
const startY = statsY + 40;
|
||||||
|
|
||||||
|
stats.forEach((stat, i) => {
|
||||||
|
const row = Math.floor(i / statsPerRow);
|
||||||
|
const col = i % statsPerRow;
|
||||||
|
const x = startX + col * (statBoxWidth + 50);
|
||||||
|
const y = startY + row * (statBoxHeight + 30);
|
||||||
|
|
||||||
|
// Stat box background
|
||||||
|
ctx.fillStyle = "rgba(255, 255, 255, 0.05)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(x, y, statBoxWidth, statBoxHeight, 20);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Stat value
|
||||||
|
ctx.fillStyle = "#5bcefa";
|
||||||
|
ctx.font = "bold 56px system-ui, -apple-system, sans-serif";
|
||||||
|
ctx.fillText(stat.value, x + 32, y + 75);
|
||||||
|
|
||||||
|
// Stat label
|
||||||
|
ctx.fillStyle = "#9ca3af";
|
||||||
|
ctx.font = "28px system-ui, -apple-system, sans-serif";
|
||||||
|
ctx.fillText(stat.label.toUpperCase(), x + 32, y + 128);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Achievement progress
|
||||||
|
const achieveY = 870;
|
||||||
|
ctx.fillStyle = "#6b7280";
|
||||||
|
ctx.font = "bold 32px system-ui, -apple-system, sans-serif";
|
||||||
|
ctx.fillText("ACHIEVEMENTS", 80, achieveY);
|
||||||
|
|
||||||
|
// Progress bar background
|
||||||
|
const progressX = 80;
|
||||||
|
const progressY = achieveY + 30;
|
||||||
|
const progressWidth = 1200;
|
||||||
|
const progressHeight = 44;
|
||||||
|
|
||||||
|
ctx.fillStyle = "rgba(255, 255, 255, 0.1)";
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(progressX, progressY, progressWidth, progressHeight, 22);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Progress bar fill
|
||||||
|
const progressGradient = ctx.createLinearGradient(progressX, 0, progressX + progressWidth, 0);
|
||||||
|
progressGradient.addColorStop(0, "#5bcefa");
|
||||||
|
progressGradient.addColorStop(0.5, "#f5a9b8");
|
||||||
|
progressGradient.addColorStop(1, "#5bcefa");
|
||||||
|
ctx.fillStyle = progressGradient;
|
||||||
|
ctx.beginPath();
|
||||||
|
ctx.roundRect(progressX, progressY, progressWidth * (achievementPercentage / 100), progressHeight, 22);
|
||||||
|
ctx.fill();
|
||||||
|
|
||||||
|
// Progress text
|
||||||
|
ctx.fillStyle = "#ffffff";
|
||||||
|
ctx.font = "bold 36px system-ui, -apple-system, sans-serif";
|
||||||
|
ctx.fillText(`${unlockedCount} / ${totalAchievements} (${achievementPercentage}%)`, progressX + progressWidth + 40, progressY + 34);
|
||||||
|
|
||||||
|
// Hikari branding
|
||||||
|
ctx.fillStyle = "#f5a9b8";
|
||||||
|
ctx.font = "bold 42px system-ui, -apple-system, sans-serif";
|
||||||
|
ctx.fillText("✨ Hikari Desktop", 80, height - 100);
|
||||||
|
|
||||||
|
// Discord promo
|
||||||
|
ctx.fillStyle = "#9ca3af";
|
||||||
|
ctx.font = "34px system-ui, -apple-system, sans-serif";
|
||||||
|
ctx.fillText("Join our community: chat.nhcarrigan.com", 80, height - 45);
|
||||||
|
|
||||||
|
// Convert canvas to blob
|
||||||
|
return new Promise((resolve) => {
|
||||||
|
canvas.toBlob((blob) => resolve(blob!), "image/png");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function shareProfile() {
|
||||||
|
isGeneratingImage = true;
|
||||||
|
try {
|
||||||
|
const blob = await generateShareImage();
|
||||||
|
const arrayBuffer = await blob.arrayBuffer();
|
||||||
|
const uint8Array = new Uint8Array(arrayBuffer);
|
||||||
|
|
||||||
|
const filePath = await save({
|
||||||
|
filters: [{ name: "PNG Image", extensions: ["png"] }],
|
||||||
|
defaultPath: `hikari-profile-${config.profile_name?.replace(/\s+/g, "-").toLowerCase() || "user"}.png`,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (filePath) {
|
||||||
|
await writeFile(filePath, uint8Array);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to generate share image:", error);
|
||||||
|
} finally {
|
||||||
|
isGeneratingImage = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="profile-overlay" role="dialog" aria-modal="true">
|
||||||
|
<div class="profile-panel">
|
||||||
|
<div class="profile-header">
|
||||||
|
<h2>Profile</h2>
|
||||||
|
<button class="close-btn" on:click={onClose} aria-label="Close profile">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M18 6L6 18M6 6l12 12" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="profile-content">
|
||||||
|
<!-- Avatar Section -->
|
||||||
|
<div class="avatar-section">
|
||||||
|
<div class="avatar-container" on:click={selectAvatar} role="button" tabindex="0" on:keydown={(e) => e.key === 'Enter' && selectAvatar()}>
|
||||||
|
{#if avatarDataUrl}
|
||||||
|
<img src={avatarDataUrl} alt="Profile avatar" class="avatar-image" />
|
||||||
|
<div class="avatar-overlay">
|
||||||
|
<span>Change</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="avatar-placeholder">
|
||||||
|
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">
|
||||||
|
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
|
||||||
|
<circle cx="12" cy="7" r="4" />
|
||||||
|
</svg>
|
||||||
|
<span>Add Photo</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{#if config.profile_avatar_path}
|
||||||
|
<button class="remove-avatar-btn" on:click={removeAvatar}>Remove Photo</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Name Section -->
|
||||||
|
<div class="name-section">
|
||||||
|
{#if editingName}
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
bind:value={nameInput}
|
||||||
|
placeholder="Enter your name"
|
||||||
|
class="name-input"
|
||||||
|
on:keydown={(e) => e.key === 'Enter' && saveName()}
|
||||||
|
on:blur={saveName}
|
||||||
|
/>
|
||||||
|
{:else}
|
||||||
|
<button class="name-display" on:click={() => { editingName = true; nameInput = config.profile_name || ""; }}>
|
||||||
|
<span class="name-text">{config.profile_name || "Click to add name"}</span>
|
||||||
|
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M11 4H4a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h14a2 2 0 0 0 2-2v-7" />
|
||||||
|
<path d="M18.5 2.5a2.121 2.121 0 0 1 3 3L12 15l-4 1 1-4 9.5-9.5z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Bio Section -->
|
||||||
|
<div class="bio-section">
|
||||||
|
<h3>Bio</h3>
|
||||||
|
{#if editingBio}
|
||||||
|
<textarea
|
||||||
|
bind:value={bioInput}
|
||||||
|
placeholder="Tell us about yourself..."
|
||||||
|
class="bio-input"
|
||||||
|
rows="3"
|
||||||
|
on:blur={saveBio}
|
||||||
|
></textarea>
|
||||||
|
<button class="save-bio-btn btn-trans-gradient" on:click={saveBio}>Save</button>
|
||||||
|
{:else}
|
||||||
|
<button class="bio-display" on:click={() => { editingBio = true; bioInput = config.profile_bio || ""; }}>
|
||||||
|
<span class="bio-text">{config.profile_bio || "Click to add a bio..."}</span>
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Stats Section -->
|
||||||
|
<div class="stats-section">
|
||||||
|
<h3>Lifetime Stats</h3>
|
||||||
|
<div class="stats-grid">
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{$formattedStats.messagesTotal}</span>
|
||||||
|
<span class="stat-label">Messages</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{$formattedStats.totalTokens}</span>
|
||||||
|
<span class="stat-label">Tokens</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{$formattedStats.codeBlocksTotal}</span>
|
||||||
|
<span class="stat-label">Code Blocks</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{$formattedStats.filesEditedTotal}</span>
|
||||||
|
<span class="stat-label">Files Edited</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{$formattedStats.filesCreatedTotal}</span>
|
||||||
|
<span class="stat-label">Files Created</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat-card">
|
||||||
|
<span class="stat-value">{$formattedStats.totalCost}</span>
|
||||||
|
<span class="stat-label">Total Cost</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Achievements Section -->
|
||||||
|
<div class="achievements-section">
|
||||||
|
<h3>Achievements</h3>
|
||||||
|
<div class="achievement-progress">
|
||||||
|
<div class="progress-bar">
|
||||||
|
<div class="progress-fill" style="width: {achievementPercentage}%"></div>
|
||||||
|
</div>
|
||||||
|
<span class="progress-text">{unlockedCount} / {totalAchievements} ({achievementPercentage}%)</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Share Section -->
|
||||||
|
<div class="share-section">
|
||||||
|
<button class="share-btn btn-trans-gradient" on:click={shareProfile} disabled={isGeneratingImage}>
|
||||||
|
{#if isGeneratingImage}
|
||||||
|
<span class="spinner"></span>
|
||||||
|
Generating...
|
||||||
|
{:else}
|
||||||
|
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||||
|
<path d="M4 12v8a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2v-8" />
|
||||||
|
<polyline points="16 6 12 2 8 6" />
|
||||||
|
<line x1="12" y1="2" x2="12" y2="15" />
|
||||||
|
</svg>
|
||||||
|
Share Profile
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
<p class="share-hint">Generate a shareable image of your profile</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.profile-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.7);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-panel {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-radius: 16px;
|
||||||
|
width: 90%;
|
||||||
|
max-width: 480px;
|
||||||
|
max-height: 85vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 20px 24px;
|
||||||
|
border-bottom: 1px solid var(--border-color);
|
||||||
|
position: sticky;
|
||||||
|
top: 0;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-header h2 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.close-btn:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.profile-content {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Avatar */
|
||||||
|
.avatar-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container {
|
||||||
|
width: 120px;
|
||||||
|
height: 120px;
|
||||||
|
border-radius: 50%;
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 3px solid var(--trans-pink);
|
||||||
|
box-shadow: 0 0 20px rgba(245, 169, 184, 0.3);
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container:hover {
|
||||||
|
border-color: var(--trans-blue);
|
||||||
|
box-shadow: 0 0 30px rgba(91, 206, 250, 0.4);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-image {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-overlay {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
background: rgba(0, 0, 0, 0.6);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
color: white;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-container:hover .avatar-overlay {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.avatar-placeholder span {
|
||||||
|
font-size: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-avatar-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
}
|
||||||
|
|
||||||
|
.remove-avatar-btn:hover {
|
||||||
|
color: var(--trans-pink);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Name */
|
||||||
|
.name-section {
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-display {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border-radius: 8px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-display:hover {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-display svg {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-display:hover svg {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-text {
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-input {
|
||||||
|
width: 100%;
|
||||||
|
max-width: 300px;
|
||||||
|
padding: 12px 16px;
|
||||||
|
font-size: 24px;
|
||||||
|
font-weight: 600;
|
||||||
|
text-align: center;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 2px solid var(--trans-pink);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.name-input:focus {
|
||||||
|
border-color: var(--trans-blue);
|
||||||
|
box-shadow: 0 0 10px rgba(91, 206, 250, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Bio */
|
||||||
|
.bio-section h3,
|
||||||
|
.stats-section h3,
|
||||||
|
.achievements-section h3 {
|
||||||
|
margin: 0 0 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-display {
|
||||||
|
width: 100%;
|
||||||
|
text-align: left;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
padding: 12px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-display:hover {
|
||||||
|
border-color: var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-text {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 12px;
|
||||||
|
font-size: 14px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 2px solid var(--trans-pink);
|
||||||
|
border-radius: 8px;
|
||||||
|
color: var(--text-primary);
|
||||||
|
resize: none;
|
||||||
|
outline: none;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bio-input:focus {
|
||||||
|
border-color: var(--trans-blue);
|
||||||
|
box-shadow: 0 0 10px rgba(91, 206, 250, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-bio-btn {
|
||||||
|
margin-top: 8px;
|
||||||
|
padding: 8px 16px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Stats */
|
||||||
|
.stats-grid {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(3, 1fr);
|
||||||
|
gap: 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-card {
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 12px;
|
||||||
|
padding: 16px 12px;
|
||||||
|
text-align: center;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 4px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-value {
|
||||||
|
font-size: 20px;
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--text-primary);
|
||||||
|
background: linear-gradient(135deg, var(--trans-blue), var(--trans-pink));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-label {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Achievements */
|
||||||
|
.achievement-progress {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
height: 12px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border-radius: 6px;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background: linear-gradient(90deg, var(--trans-blue), var(--trans-pink), var(--trans-white), var(--trans-pink), var(--trans-blue));
|
||||||
|
background-size: 200% 100%;
|
||||||
|
animation: shimmer 3s linear infinite;
|
||||||
|
border-radius: 6px;
|
||||||
|
transition: width 0.5s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes shimmer {
|
||||||
|
0% { background-position: 200% 0; }
|
||||||
|
100% { background-position: -200% 0; }
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 14px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Share */
|
||||||
|
.share-section {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding-top: 8px;
|
||||||
|
border-top: 1px solid var(--border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 12px 24px;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 15px;
|
||||||
|
font-weight: 600;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn:disabled {
|
||||||
|
opacity: 0.7;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-btn svg {
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.share-hint {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.spinner {
|
||||||
|
width: 16px;
|
||||||
|
height: 16px;
|
||||||
|
border: 2px solid transparent;
|
||||||
|
border-top-color: currentColor;
|
||||||
|
border-radius: 50%;
|
||||||
|
animation: spin 0.8s linear infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes spin {
|
||||||
|
to { transform: rotate(360deg); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -14,7 +14,6 @@
|
|||||||
<div class="stats-row">
|
<div class="stats-row">
|
||||||
<span class="stat-label">Messages:</span>
|
<span class="stat-label">Messages:</span>
|
||||||
<span class="stat-value">{$formattedStats.messagesSession}</span>
|
<span class="stat-value">{$formattedStats.messagesSession}</span>
|
||||||
<span class="stat-secondary">/ {$formattedStats.messagesTotal}</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats-section">
|
<div class="stats-section">
|
||||||
@@ -32,11 +31,6 @@
|
|||||||
<span class="stat-label">Output:</span>
|
<span class="stat-label">Output:</span>
|
||||||
<span class="stat-value">{$formattedStats.sessionOutputTokens}</span>
|
<span class="stat-value">{$formattedStats.sessionOutputTokens}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row stat-highlight">
|
|
||||||
<span class="stat-label">Total:</span>
|
|
||||||
<span class="stat-value">{$formattedStats.totalTokens}</span>
|
|
||||||
<span class="stat-cost">{$formattedStats.totalCost}</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="stats-section">
|
<div class="stats-section">
|
||||||
@@ -44,17 +38,14 @@
|
|||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">Code blocks:</span>
|
<span class="stat-label">Code blocks:</span>
|
||||||
<span class="stat-value">{$formattedStats.codeBlocksSession}</span>
|
<span class="stat-value">{$formattedStats.codeBlocksSession}</span>
|
||||||
<span class="stat-secondary">/ {$formattedStats.codeBlocksTotal}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">Files edited:</span>
|
<span class="stat-label">Files edited:</span>
|
||||||
<span class="stat-value">{$formattedStats.filesEditedSession}</span>
|
<span class="stat-value">{$formattedStats.filesEditedSession}</span>
|
||||||
<span class="stat-secondary">/ {$formattedStats.filesEditedTotal}</span>
|
|
||||||
</div>
|
</div>
|
||||||
<div class="stat-row">
|
<div class="stat-row">
|
||||||
<span class="stat-label">Files created:</span>
|
<span class="stat-label">Files created:</span>
|
||||||
<span class="stat-value">{$formattedStats.filesCreatedSession}</span>
|
<span class="stat-value">{$formattedStats.filesCreatedSession}</span>
|
||||||
<span class="stat-secondary">/ {$formattedStats.filesCreatedTotal}</span>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -22,6 +22,7 @@
|
|||||||
import { achievementProgress } from "$lib/stores/achievements";
|
import { achievementProgress } from "$lib/stores/achievements";
|
||||||
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
import SessionHistoryPanel from "./SessionHistoryPanel.svelte";
|
||||||
import GitPanel from "./GitPanel.svelte";
|
import GitPanel from "./GitPanel.svelte";
|
||||||
|
import ProfilePanel from "./ProfilePanel.svelte";
|
||||||
|
|
||||||
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
const DISCORD_URL = "https://chat.nhcarrigan.com";
|
||||||
const DONATE_URL = "https://donate.nhcarrigan.com";
|
const DONATE_URL = "https://donate.nhcarrigan.com";
|
||||||
@@ -38,6 +39,7 @@
|
|||||||
let showKeyboardShortcuts = $state(false);
|
let showKeyboardShortcuts = $state(false);
|
||||||
let showSessionHistory = $state(false);
|
let showSessionHistory = $state(false);
|
||||||
let showGitPanel = $state(false);
|
let showGitPanel = $state(false);
|
||||||
|
let showProfile = $state(false);
|
||||||
const progress = $derived($achievementProgress);
|
const progress = $derived($achievementProgress);
|
||||||
let currentConfig: HikariConfig = $state({
|
let currentConfig: HikariConfig = $state({
|
||||||
model: null,
|
model: null,
|
||||||
@@ -58,6 +60,9 @@
|
|||||||
streamer_mode: false,
|
streamer_mode: false,
|
||||||
streamer_hide_paths: false,
|
streamer_hide_paths: false,
|
||||||
compact_mode: false,
|
compact_mode: false,
|
||||||
|
profile_name: null,
|
||||||
|
profile_avatar_path: null,
|
||||||
|
profile_bio: null,
|
||||||
});
|
});
|
||||||
|
|
||||||
let streamerModeActive = $state(false);
|
let streamerModeActive = $state(false);
|
||||||
@@ -222,6 +227,20 @@
|
|||||||
title="Streamer mode active (Ctrl+Shift+S to toggle)"
|
title="Streamer mode active (Ctrl+Shift+S to toggle)"
|
||||||
></div>
|
></div>
|
||||||
{/if}
|
{/if}
|
||||||
|
<button
|
||||||
|
onclick={() => (showProfile = true)}
|
||||||
|
class="p-1 text-gray-500 icon-trans-hover"
|
||||||
|
title="Profile"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path
|
||||||
|
stroke-linecap="round"
|
||||||
|
stroke-linejoin="round"
|
||||||
|
stroke-width="2"
|
||||||
|
d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
<button
|
<button
|
||||||
onclick={onToggleCompact}
|
onclick={onToggleCompact}
|
||||||
class="p-1 text-gray-500 icon-trans-hover"
|
class="p-1 text-gray-500 icon-trans-hover"
|
||||||
@@ -438,3 +457,7 @@
|
|||||||
{#if showGitPanel}
|
{#if showGitPanel}
|
||||||
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
|
<GitPanel isOpen={showGitPanel} onClose={() => (showGitPanel = false)} />
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
|
{#if showProfile}
|
||||||
|
<ProfilePanel onClose={() => (showProfile = false)} />
|
||||||
|
{/if}
|
||||||
|
|||||||
+1128
-83
File diff suppressed because it is too large
Load Diff
@@ -22,6 +22,9 @@ export interface HikariConfig {
|
|||||||
streamer_mode: boolean;
|
streamer_mode: boolean;
|
||||||
streamer_hide_paths: boolean;
|
streamer_hide_paths: boolean;
|
||||||
compact_mode: boolean;
|
compact_mode: boolean;
|
||||||
|
profile_name: string | null;
|
||||||
|
profile_avatar_path: string | null;
|
||||||
|
profile_bio: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultConfig: HikariConfig = {
|
const defaultConfig: HikariConfig = {
|
||||||
@@ -43,6 +46,9 @@ const defaultConfig: HikariConfig = {
|
|||||||
streamer_mode: false,
|
streamer_mode: false,
|
||||||
streamer_hide_paths: false,
|
streamer_hide_paths: false,
|
||||||
compact_mode: false,
|
compact_mode: false,
|
||||||
|
profile_name: null,
|
||||||
|
profile_avatar_path: null,
|
||||||
|
profile_bio: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
function createConfigStore() {
|
function createConfigStore() {
|
||||||
|
|||||||
@@ -104,10 +104,11 @@ export async function initStatsListener() {
|
|||||||
stats.set(newStats);
|
stats.set(newStats);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Load initial stats from backend
|
// Load initial persisted stats from backend (no bridge required)
|
||||||
try {
|
try {
|
||||||
const initialStats = await invoke<UsageStats>("get_usage_stats");
|
const initialStats = await invoke<UsageStats>("get_persisted_stats");
|
||||||
stats.set(initialStats);
|
stats.set(initialStats);
|
||||||
|
console.log("Loaded persisted stats:", initialStats);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Failed to load initial stats:", error);
|
console.error("Failed to load initial stats:", error);
|
||||||
}
|
}
|
||||||
|
|||||||
+173
-18
@@ -14,34 +14,52 @@ export type AchievementId =
|
|||||||
| "GrowingStrong" // 10,000 tokens
|
| "GrowingStrong" // 10,000 tokens
|
||||||
| "BlossomingCoder" // 100,000 tokens
|
| "BlossomingCoder" // 100,000 tokens
|
||||||
| "TokenMaster" // 1,000,000 tokens
|
| "TokenMaster" // 1,000,000 tokens
|
||||||
|
| "TokenBillionaire" // 10,000,000 tokens
|
||||||
|
| "TokenTreasure" // 50,000,000 tokens
|
||||||
|
|
||||||
// Code Generation
|
// Code Generation
|
||||||
| "HelloWorld" // First code block
|
| "HelloWorld" // First code block
|
||||||
| "CodeWizard" // 100 code blocks
|
| "CodeWizard" // 100 code blocks
|
||||||
| "ThousandBlocks" // 1,000 code blocks
|
| "ThousandBlocks" // 1,000 code blocks
|
||||||
|
| "CodeFactory" // 5,000 code blocks
|
||||||
|
| "CodeEmpire" // 10,000 code blocks
|
||||||
|
|
||||||
// File Operations
|
// File Operations
|
||||||
| "FileManipulator" // 10 files edited
|
| "FileManipulator" // 10 files edited
|
||||||
| "FileArchitect" // 100 files edited
|
| "FileArchitect" // 100 files edited
|
||||||
|
| "FileEngineer" // 500 files edited
|
||||||
|
| "FileLegend" // 1,000 files edited
|
||||||
|
|
||||||
// Conversation milestones
|
// Conversation milestones
|
||||||
| "ConversationStarter" // 10 messages
|
| "ConversationStarter" // 10 messages
|
||||||
| "ChattyKathy" // 100 messages
|
| "ChattyKathy" // 100 messages
|
||||||
| "Conversationalist" // 1,000 messages
|
| "Conversationalist" // 1,000 messages
|
||||||
|
| "ChatMarathon" // 5,000 messages
|
||||||
|
| "ChatLegend" // 10,000 messages
|
||||||
|
|
||||||
// Tool usage
|
// Tool usage
|
||||||
| "Toolsmith" // 5 different tools
|
| "Toolsmith" // 5 different tools
|
||||||
| "ToolMaster" // 10 different tools
|
| "ToolMaster" // 10 different tools
|
||||||
|
|
||||||
// Time-based achievements
|
// Time-based achievements
|
||||||
| "EarlyBird" // Started session 5-7 AM
|
| "EarlyBird" // Started session 5-7 AM
|
||||||
| "NightOwl" // Coding after midnight
|
| "NightOwl" // Coding after midnight
|
||||||
| "AllNighter" // Worked 2-5 AM
|
| "AllNighter" // Worked 2-5 AM
|
||||||
| "WeekendWarrior" // Coding on weekend
|
| "WeekendWarrior" // Coding on weekend
|
||||||
| "DedicatedDeveloper" // 30 days in a row
|
| "DedicatedDeveloper" // 30 days in a row
|
||||||
|
|
||||||
// Search and exploration
|
// Search and exploration
|
||||||
| "Explorer" // 50 searches
|
| "Explorer" // 50 searches
|
||||||
| "MasterSearcher" // 500 searches
|
| "MasterSearcher" // 500 searches
|
||||||
|
|
||||||
// Session achievements
|
// Session achievements
|
||||||
| "QuickSession" // Productive session < 5 min
|
| "QuickSession" // Productive session < 5 min
|
||||||
| "FocusedWork" // 30 min session
|
| "FocusedWork" // 30 min session
|
||||||
| "DeepDive" // 2 hour session
|
| "DeepDive" // 2 hour session
|
||||||
| "MarathonSession" // 5+ hour session
|
| "MarathonSession" // 5+ hour session
|
||||||
|
| "UltraMarathon" // 8 hour session
|
||||||
|
| "CodingRetreat" // 12 hour session
|
||||||
|
|
||||||
// Special achievements
|
// Special achievements
|
||||||
| "FirstMessage" // First message sent
|
| "FirstMessage" // First message sent
|
||||||
| "FirstTool" // First tool used
|
| "FirstTool" // First tool used
|
||||||
@@ -51,28 +69,165 @@ export type AchievementId =
|
|||||||
| "SpeedCoder" // 10 code blocks in 10 minutes
|
| "SpeedCoder" // 10 code blocks in 10 minutes
|
||||||
| "ClaudeConnoisseur" // Used all Claude models
|
| "ClaudeConnoisseur" // Used all Claude models
|
||||||
| "MarathonCoder" // 10k tokens in one session
|
| "MarathonCoder" // 10k tokens in one session
|
||||||
|
|
||||||
// Relationship & Greetings
|
// Relationship & Greetings
|
||||||
| "GoodMorning" // Said good morning
|
| "GoodMorning" // Say "good morning"
|
||||||
| "GoodNight" // Said good night
|
| "GoodNight" // Say "good night" or "goodnight"
|
||||||
| "ThankYou" // Said thank you
|
| "ThankYou" // Say "thank you" or "thanks"
|
||||||
| "LoveYou" // Said love you
|
| "LoveYou" // Say "love you" or "ily"
|
||||||
|
| "HelloHikari" // Say "hello hikari" or "hi hikari"
|
||||||
|
| "HowAreYou" // Ask "how are you"
|
||||||
|
| "MissedYou" // Say "missed you"
|
||||||
|
| "BackAgain" // Say "i'm back" or "back again"
|
||||||
|
|
||||||
// Personality & Fun
|
// Personality & Fun
|
||||||
| "EmojiUser" // Used 20+ emojis
|
| "EmojiUser" // Use an emoji in a message
|
||||||
| "CapsLock" // ALL CAPS MESSAGE
|
| "QuestionMaster" // Use "?" in 20 messages
|
||||||
| "QuestionMaster" // Asked 50 questions
|
| "CapsLock" // Send a message in ALL CAPS
|
||||||
| "PleaseAndThankYou" // Polite user
|
| "PleaseAndThankYou" // Use "please" in messages
|
||||||
|
|
||||||
|
// Emotional
|
||||||
|
| "Frustrated" // Say "frustrated" or "ugh" or "argh"
|
||||||
|
| "Excited" // Say "excited" or "yay" or "woohoo"
|
||||||
|
| "Confused" // Say "confused" or "don't understand"
|
||||||
|
| "Curious" // Ask "why" or "how does"
|
||||||
|
| "Impressed" // Say "wow" or "amazing" or "incredible"
|
||||||
|
|
||||||
// Git & Development
|
// Git & Development
|
||||||
| "CommitMaster" // 100 commits
|
| "GitGuru" // Use git commands 10 times
|
||||||
| "PRO" // Created 10 PRs
|
| "TestWriter" // Create test files
|
||||||
| "Reviewer" // Reviewed 10 PRs
|
| "Debugger" // Fix bugs (messages with "fix", "bug", "error")
|
||||||
| "IssueTracker" // Created 25 issues
|
| "CommitKing" // 50 commits
|
||||||
| "GitGuru" // Used git commands
|
| "CommitLegend" // 200 commits
|
||||||
|
| "BranchMaster" // Create 10 branches
|
||||||
|
| "MergeExpert" // Merge 20 PRs
|
||||||
|
| "ConflictResolver" // Resolve merge conflicts
|
||||||
|
|
||||||
// Tool Mastery
|
// Tool Mastery
|
||||||
| "BashMaster" // Used bash 100 times
|
| "BashMaster" // Use Bash tool 50 times
|
||||||
| "FileExplorer" // Searched files 100 times
|
| "FileExplorer" // Use Read tool 100 times
|
||||||
| "SearchExpert" // Advanced searches
|
| "SearchExpert" // Use Grep tool 50 times
|
||||||
| "AgentCommander" // Used task agents
|
| "EditMaster" // Use Edit tool 100 times
|
||||||
| "MCPMaster"; // Used MCP tools
|
| "WriteMaster" // Use Write tool 50 times
|
||||||
|
| "GlobMaster" // Use Glob tool 100 times
|
||||||
|
| "TaskMaster" // Use Task tool 50 times
|
||||||
|
| "WebFetcher" // Use WebFetch tool 20 times
|
||||||
|
| "McpExplorer" // Use MCP tools 50 times
|
||||||
|
|
||||||
|
// Daily Streaks
|
||||||
|
| "WeekStreak" // 7 days in a row
|
||||||
|
| "TwoWeekStreak" // 14 days in a row
|
||||||
|
| "MonthStreak" // 30 days in a row
|
||||||
|
| "QuarterStreak" // 90 days in a row
|
||||||
|
|
||||||
|
// Time Challenges
|
||||||
|
| "MorningPerson" // 10 sessions started before 9 AM
|
||||||
|
| "NightCoder" // 10 sessions after 10 PM
|
||||||
|
| "LunchBreakCoder" // Session during 12-1 PM
|
||||||
|
| "CoffeeTime" // Session during 3-4 PM
|
||||||
|
|
||||||
|
// Day-specific
|
||||||
|
| "MondayMotivation" // Coding on Monday
|
||||||
|
| "FridayFinisher" // Coding on Friday
|
||||||
|
| "HumpDay" // Coding on Wednesday
|
||||||
|
|
||||||
|
// Seasonal/Special Times
|
||||||
|
| "NewYearCoder" // Coding on January 1st
|
||||||
|
| "ValentinesDev" // Coding on February 14th
|
||||||
|
| "SpookyCode" // Coding on October 31st
|
||||||
|
| "HolidayCoder" // Coding on December 25th
|
||||||
|
| "LeapDayCoder" // Coding on February 29th
|
||||||
|
|
||||||
|
// Message Content
|
||||||
|
| "LongMessage" // Send a message over 500 characters
|
||||||
|
| "NovelWriter" // Send a message over 2000 characters
|
||||||
|
| "ShortAndSweet" // Complete a task with messages under 50 chars each
|
||||||
|
| "CodeInMessage" // Include code block in user message
|
||||||
|
| "MarkdownMaster" // Use markdown formatting in message
|
||||||
|
|
||||||
|
// Programming Languages
|
||||||
|
| "RustDeveloper" // Generate Rust code
|
||||||
|
| "PythonDeveloper" // Generate Python code
|
||||||
|
| "JavaScriptDev" // Generate JavaScript code
|
||||||
|
| "TypeScriptDev" // Generate TypeScript code
|
||||||
|
| "GoDeveloper" // Generate Go code
|
||||||
|
| "CppDeveloper" // Generate C++ code
|
||||||
|
| "JavaDeveloper" // Generate Java code
|
||||||
|
| "HtmlCssDev" // Generate HTML/CSS code
|
||||||
|
| "SqlDeveloper" // Generate SQL code
|
||||||
|
| "ShellScripter" // Generate shell/bash scripts
|
||||||
|
| "FullStackDev" // Generate code in 10+ languages
|
||||||
|
|
||||||
|
// Project Types
|
||||||
|
| "FrontendDev" // Work on frontend files
|
||||||
|
| "BackendDev" // Work on backend files
|
||||||
|
| "ConfigEditor" // Edit config files
|
||||||
|
| "DocWriter" // Edit documentation
|
||||||
|
|
||||||
|
// Error Handling
|
||||||
|
| "ErrorHunter" // Fix 10 errors
|
||||||
|
| "ExceptionSlayer" // Fix 50 errors
|
||||||
|
| "BugExterminator" // Fix 100 bugs
|
||||||
|
|
||||||
|
// Refactoring
|
||||||
|
| "CleanCoder" // Refactor code
|
||||||
|
| "Optimizer" // Optimize performance
|
||||||
|
| "Simplifier" // Simplify complex code
|
||||||
|
|
||||||
|
// Testing
|
||||||
|
| "TestNovice" // Write 10 tests
|
||||||
|
| "TestEnthusiast" // Write 50 tests
|
||||||
|
| "TestMaster" // Write 100 tests
|
||||||
|
| "CoverageKing" // Achieve test coverage mentions
|
||||||
|
|
||||||
|
// Documentation
|
||||||
|
| "Documenter" // Write documentation
|
||||||
|
| "CommentWriter" // Add comments to code
|
||||||
|
| "ReadmeHero" // Create/edit README files
|
||||||
|
|
||||||
|
// API & Integration
|
||||||
|
| "ApiExplorer" // Work with APIs
|
||||||
|
| "DatabaseDev" // Work with databases
|
||||||
|
| "CloudCoder" // Work with cloud services
|
||||||
|
|
||||||
|
// Special Milestones
|
||||||
|
| "CenturyClub" // 100 sessions
|
||||||
|
| "ThousandSessions" // 1000 sessions
|
||||||
|
| "Veteran" // Used Hikari for 30+ days total
|
||||||
|
| "OldTimer" // Used Hikari for 90+ days total
|
||||||
|
| "Loyalist" // Used Hikari for 365+ days total
|
||||||
|
|
||||||
|
// Fun & Easter Eggs
|
||||||
|
| "Perfectionist" // Redo something 5 times
|
||||||
|
| "Persistent" // Ask same question 3 times
|
||||||
|
| "Patient" // Wait for long response
|
||||||
|
| "Speedy" // Send 10 messages in 1 minute
|
||||||
|
|
||||||
|
// UI Exploration
|
||||||
|
| "MultiTasker" // Multiple conversations
|
||||||
|
| "Minimalist" // Use compact mode
|
||||||
|
| "PrivacyFirst" // Use streamer mode
|
||||||
|
| "ThemeChanger" // Change themes
|
||||||
|
| "SettingsTweaker" // Adjust settings
|
||||||
|
|
||||||
|
// Achievement Meta
|
||||||
|
| "AchievementHunter" // Unlock 25 achievements
|
||||||
|
| "Completionist" // Unlock 50 achievements
|
||||||
|
| "MasterUnlocker" // Unlock 100 achievements
|
||||||
|
| "PlatinumStatus" // Unlock all achievements
|
||||||
|
|
||||||
|
// Clipboard & Snippets
|
||||||
|
| "ClipboardCollector" // Use clipboard history
|
||||||
|
| "SnippetCreator" // Create a snippet
|
||||||
|
| "SnippetMaster" // Create 20 snippets
|
||||||
|
|
||||||
|
// Other Features
|
||||||
|
| "QuickActionUser" // Use quick actions
|
||||||
|
| "HistoryBuff" // Browse history
|
||||||
|
| "Archivist" // Export sessions
|
||||||
|
| "SessionExporter" // Export a session
|
||||||
|
| "GitPanelUser" // Use Git panel
|
||||||
|
| "FeatureExplorer"; // Use 10+ features
|
||||||
|
|
||||||
export interface Achievement {
|
export interface Achievement {
|
||||||
id: AchievementId;
|
id: AchievementId;
|
||||||
|
|||||||
Reference in New Issue
Block a user