WPFで作る軽量デスクトップAIアシスタント

## はじめに

Windows環境では昔からさまざまなデスクトップマスコットが存在していましたが、最近は見かけなくなりました。そこで、最新のLLM技術とデスクトップマスコットの良さを組み合わせた「日記作成アシスタント」を作ってみました。

https://github.com/kame404/DiaryAssistant

このアプリの特徴は、**キャラクター性を重視した設計**と、**メモリ使用量約20MBという軽量な実装**です。さらに、**動的なプロンプト生成**によって、アシスタントの会話能力を最大限に引き出しています。

今回は、このアプリの技術的な詳細と実装上の工夫について解説します。

### 紹介動画

https://x.com/kame404/status/1899045064531214455

## 技術スタックと選定理由

### フレームワーク選定

アプリケーション開発においてフレームワーク選定は重要ですが、以下のような選択肢がありました：

- **Electron**: クロスプラットフォーム対応が魅力的ですが、メモリ使用量が大きい
- **Unity**: 3Dキャラクターなどリッチな表現が可能ですが、オーバースペックかつリソース消費が大きい
- **WPF (.NET Framework 4.8)**: Windowsネイティブでリソース効率が良く、4K等の高解像度対応済み

結果として、**WPF (.NET Framework 4.8)** を選択しました。理由は主に以下の3点です：

1. 枯れた技術であり、安定性が高く広範なPC環境で動作する
2. バイナリサイズを小さく抑えられる
3. 高解像度ディスプレイに適切にスケーリングする

### データベース

データベースはシンプルな[**LiteDB**](https://www.litedb.org/)を採用しました。単一ファイルでデータを管理できる軽量な組み込み型NoSQLデータベースであり、C#プロジェクトとの親和性が高いためです。ローカルで動作するC#アプリケーションのデータストアとしては最適の選択肢だと考えています。

### LLM API連携

LLM（大規模言語モデル）APIとの連携は、[**RestSharp**](https://restsharp.dev/)を使用して実装しています。また、プライバシー保護のため、[**Ollama**](https://ollama.com/)によるローカルLLM実行もサポートしています。

### 全体アーキテクチャ

アプリケーションの全体構成は以下のようになっています：

```mermaid
graph TD
    A[MainWindow] --> B[NotificationService]
    A --> C[DatabaseService]
    A --> D[AssistantManager]
    B --> E[GeminiService]
    E -->|API連携| F[Google Gemini API]
    E -->|ローカル実行| G[Ollama]
    D --> H[ResourceService]
    C --> I[(LiteDB)]
    
    subgraph アシスタント管理
    D --> J[Assistant]
    J --> K[Config]
    J --> L[Icons]
    J --> M[Prompts]
    end
    
    style E fill:#f6d5ff,stroke:#333,stroke-width:2px
    style F fill:#d4f9ff,stroke:#333,stroke-width:2px
    style G fill:#d4f9ff,stroke:#333,stroke-width:2px
    style J fill:#ffe8d4,stroke:#333,stroke-width:2px
```

## プロンプトエンジニアリング

このアプリケーションの中核となるのが、**状況に合わせて動的に生成されるプロンプト**です。単純にユーザーの入力をLLMに渡すだけでなく、アプリケーション側でコンテキストを豊かにすることで、より自然な応答を実現しています。

### 動的プロンプト生成の仕組み

プロンプトは以下の要素を組み合わせて動的に生成されます：

```mermaid
graph TD
    A[動的プロンプト生成] --> B[システムプロンプト]
    A --> C[ユーザー情報]
    A --> D[時間情報]
    A --> E[会話履歴]
    A --> F[状況情報]
    A --> G[過去データ参照]
    
    B --> H[アシスタント性格設定]
    C --> I[名前/設定]
    D --> J[現在時刻/曜日]
    E --> K[最近のやり取り]
    F --> L[アクティブウィンドウ情報]
    G --> M[過去の記録データ]
    
    H --> N[最終プロンプト]
    I --> N
    J --> N
    K --> N
    L --> N
    M --> N
```


実際のプロンプト例（独り言モード）：

```
あなたは、ユーザーの傍らにいるAIアシスタントです。名前は「ソフィア」です。
落ち着いた口調で、敬語を使って自然な独り言をつぶやきます。

アシスタント名: ソフィア
ユーザー名: kame404
現在時刻: 2025/03/10 14:32
現在の曜日: 月曜日
アクティブウィンドウ: Microsoft Word
モード: 独り言

あなたは独り言モードで、ユーザーに話しかけるのではなく、ユーザーの状況に合わせた
自然な独り言をつぶやきます。

## 本日記録した箇条書きメモ：
- 朝からコーヒーを3杯も飲んでしまった
- 卒論の締め切りが1週間後に迫っている
- 夕方から雨の予報

## 過去数日の記録：
[2025/03/09] ポイント: 友人と映画を観た, 10時間寝た, 明日から本格的に卒論に取り掛かる
[2025/03/08] ポイント: 買い物に行った, 新しい本を購入

現在は昼の時間帯です。

次のような独り言を<response>タグ内に生成してください：
1. 短めの文章（1〜3文程度）
2. 日記を書かせようとする質問や促しはNG
3. ユーザーの現在の状況（アクティブウィンドウ、時間帯）に関連した内容
4. 敬語で、まるでそばにいるAIアシスタントがつぶやいているような印象
5. 感情属性（emotion）を適切なものにする
```

### 過去データの最適化参照

会話の文脈を維持するため、過去のデータを参照していますが、プロンプトが長くなりすぎないよう以下の工夫をしています：

1. **時間的制約**: 過去数日以内のデータのみを参照
2. **件数制限**: 最新の数件までに制限
3. **LLM自身による要約利用**: 長い会話は自動的に要約し、その要約を参照

```csharp
// 過去数日の日記データを含める - 件数制限あり
var pastWeekEntries = DatabaseService.GetAllDiaryEntries()
    .Where(e => e.Date >= DateTime.Today.AddDays(-7) && e.Date < DateTime.Today)
    .OrderByDescending(e => e.Date)
    .Take(3) // 最新3件のみ
    .ToList();

if (pastWeekEntries.Count > 0)
{
    finalPrompt += "## 過去数日の記録：\n";
    foreach (var entry in pastWeekEntries)
    {
        if (entry.BulletPoints != null && entry.BulletPoints.Count > 0)
        {
            finalPrompt += $"[{entry.Date:yyyy/MM/dd}] ポイント: ";
            finalPrompt += string.Join(", ", entry.BulletPoints.Take(3).Select(p => p.Content));
            finalPrompt += "\n";
        }
    }
    finalPrompt += "\n";
}
```

### 複数の特化モード

アプリには以下の複数の特化モードがあり、それぞれ最適化されたプロンプト設計を持ちます：

1. **独り言モード**: アシスタントが自然な独り言をランダムなタイミングで話しかける
![notification](https://github.com/user-attachments/assets/38169e8b-74a0-40b6-9c27-6a44a742366f)
このようにデスクトップの右下に通知が来る。
2. **日記モード**: ユーザーとの対話を通して一日の振り返りをサポート
3. **要点抽出モード**: 会話から重要なポイントを抽出し箇条書きにまとめる
![list](https://github.com/user-attachments/assets/5eccaf30-cf37-408e-a555-ed47bc75c343)
このように要点抽出モードで生成された箇条書きが表示されている。
4. **要約モード**: 長い会話を簡潔に要約する


各モードでのプロンプト例（一部）：

```csharp
// 要点抽出モードのプロンプト例
string finalPrompt = "以下の会話からユーザーが報告した事実や出来事のみを箇条書き形式で抽出してください。\n";
finalPrompt += "あなたの質問、提案、感想などは含めないでください。\n";
finalPrompt += "各ポイントは「- 」で始め、1行に1つの事実を簡潔に記述してください。\n";
finalPrompt += "ユーザーが言及した具体的な行動、経験、感情に焦点を当ててください。\n\n";
finalPrompt += "会話内容：\n";

// 要約モードのプロンプト例
string summaryPrompt = "以下の会話を要約し、主要なトピックを抽出してください。\n" +
                       "出力形式：\n" +
                       "<summary>会話の要約文</summary>\n" +
                       "<topics>トピック1,トピック2,トピック3</topics>\n\n" +
                       "会話内容：\n";
```

:::note info
複数のモードを切り替えることで、日記作成の様々な側面をサポートする多機能なアシスタントを実現しています。
:::

### 日記生成プロセスのフロー

```mermaid
sequenceDiagram
    participant U as ユーザー
    participant NW as 通知ウィンドウ
    participant VM as NotificationViewModel
    participant GS as GeminiService
    participant DB as DatabaseService
    
    U->>NW: 日記モード開始
    NW->>VM: StartDiaryCommand実行
    VM->>VM: IsDiaryMode = true
    VM->>GS: GetDiaryResponse(初期プロンプト)
    
    GS->>GS: プロンプト生成
    Note over GS: システムプロンプト + 会話履歴<br>+ 本日/過去の記録
    
    GS->>API: API通信
    API->>GS: 初期質問
    GS->>VM: レスポンス返却
    VM->>NW: 質問表示
    
    loop 会話繰り返し
        U->>NW: メッセージ入力
        NW->>VM: SendMessage実行
        VM->>GS: GetDiaryResponse(ユーザー入力)
        GS->>API: 会話継続プロンプト
        API->>GS: フォローアップ応答
        GS->>VM: レスポンス解析
        VM->>NW: 応答表示
    end
    
    U->>NW: 閉じる/終了
    NW->>VM: Close実行
    
    VM->>GS: GenerateBulletPointSummary
    GS->>API: 要点抽出プロンプト
    API->>GS: 箇条書き要点
    GS->>VM: 箇条書きリスト
    
    VM->>GS: SummarizeConversation
    GS->>API: 会話要約プロンプト
    API->>GS: 要約・トピックタグ
    GS->>VM: 会話要約
    
    VM->>DB: SaveDiaryEntry
    Note over VM,DB: 箇条書き+会話+要約保存
    
    VM->>NW: ウィンドウ閉じる
    NW->>U: 通知消去
```

## LLMを「キャラクター」として扱う設計

このアプリの最大の特徴は、**LLMを単なる情報提供ツールではなく、感情を持つキャラクターとして扱う設計**にあります。

### 感情表現の実装

通常のチャットAIでは、返答のテキストだけを取得して表示するのが一般的です。しかし、このアプリでは、LLMに「感情」を指定したタグを返すように指示し、それをアイコン表示に反映させています。
```
返信は<response>タグ内に記述し、感情を表す属性を付けてください。
例：<response emotion=\""happy\"">こんにちは！</response>
感情の種類: normal, happy, sad, angry, surprised, thinking
```

```csharp
// AIレスポンスから感情を抽出するメソッド
public static string ExtractEmotion(string response)
{
    try
    {
        var match = Regex.Match(response, @"<response\s+emotion=""([^""]+)"">");
        if (match.Success)
        {
            return match.Groups[1].Value;
        }
    }
    catch (Exception ex)
    {
        System.Diagnostics.Debug.WriteLine($"感情抽出エラー: {ex.Message}");
    }

    return "normal";
}
```

LLMからの応答例：
```
<response emotion="happy">今日は論文の執筆が進んでいるようですね！集中力が続いていて尊敬です！</response>
```

これにより、アシスタントは「normal」「happy」「sad」「angry」「surprised」「thinking」といった感情に応じてアイコンが変化し、より人間らしい存在感を醸し出します。

![selene](https://github.com/user-attachments/assets/d74330d2-c8bf-469f-97d8-9dc36686dbd5)

## アシスタントのカスタマイズシステム

ユーザーが自分好みのアシスタントを作れるよう、柔軟なカスタマイズシステムを実装しました。

### フォルダベースの管理機構

アシスタントの情報は以下のようなフォルダ構造で管理されます：

```
resources/
  ├─ sophia/       # デフォルトアシスタント
  │   ├─ config.json   # 基本設定
  │   ├─ prompts.json  # プロンプトテンプレート
  │   ├─ fallback.json # エラー時のメッセージ
  │   └─ icons/        # 感情表現アイコン
  │       ├─ normal.png
  │       ├─ happy.png
  │       ├─ sad.png
  │       └─ ...
  └─ [custom]/      # カスタムアシスタント
      └─ ...
```

この構造により、新しいアシスタントの追加が非常に簡単になっています。キャラクターの画像や、基本的なプロンプトを配置するだけで、独自のアシスタントを作成できます。


### カスタマイズ可能な要素

以下の要素をカスタマイズできます：

1. **アシスタントの外見**: 感情表現用アイコン（VRoidなどで作成可能）
2. **プロンプト設計**: システムプロンプト、挨拶、日記質問など
3. **エラー時の応答**: ネットワークエラーや API エラー時のメッセージ


## UI/UX設計のポイント

アプリのUIは、モダンな外観を目指しました。

![chat](https://github.com/user-attachments/assets/d311a6cc-ae47-4ac4-977c-03c3194e03f9)

### Tailwind CSSのカラーパレット活用

WPFには様々なUIライブラリがありますが、あえてUIライブラリを採用せず、軽量化やバグ軽減を実現しました。

そのかわり、Web開発でよく使われる[Tailwind CSSのカラーパレット](https://tailwindcss.com/docs/colors)をWPFアプリケーションに取り入れました。これにより、モダンで統一感のあるUI配色を実現しています。

```csharp
// Tailwind CSSのカラーパレットをWPFで再現
new SolidColorBrush(Color.FromRgb(59, 130, 246))  // blue-500
new SolidColorBrush(Color.FromRgb(241, 245, 249)) // slate-100
new SolidColorBrush(Color.FromRgb(30, 41, 59))    // slate-800
```

### タスクトレイと通知の連携

アプリは常駐型として動作し、タスクトレイアイコンから各種操作が可能です。

![tray_menu](https://github.com/user-attachments/assets/2ee676f1-152d-46c4-8027-bdbb2989ec53)

## 軽量設計

### ネイティブWPFコントロールの活用

ネイティブのWPFコントロールを使用することで、メモリ使用量を大幅に削減しています。

![taskmanager](https://github.com/user-attachments/assets/0178def7-8f57-4a33-a24b-981cf1b61739)

:::note info
メモリ使用量は約20MB程度で、Unity製やElectron製のデスクトップマスコットと比較して非常に軽量です。
:::

### リソース最適化

アイコン画像はpngquantで圧縮し、必要最小限のリソースのみをバンドルしています。また、Costura.Fodyを使用してDLLを実行ファイルに埋め込み、配布を簡素化しています。

## プライバシー

### Gemini APIとOllamaの切り替え機構

ユーザーのプライバシーを考慮し、Google Gemini APIだけでなく、ローカル環境でLLMを実行できるOllamaにも対応しています。

![ollama](https://github.com/user-attachments/assets/bc7d2dbf-52a3-4282-9b3e-0d23d7d95f07)

また、アクティブウィンドウのタイトルをアシスタントの応答に利用する機能は、デフォルトでオフにしてあります。
![active](https://github.com/user-attachments/assets/e3e0dc73-74ae-4306-9427-b357392b906e)

### 通信ログの透明性

プライバシーに配慮し、送受信データの透明性を確保するため、実際に送信されたプロンプトとレスポンスを`logs`フォルダにテキストファイルで記録しています。これにより、ユーザーは自分のどのようなデータがAPIに送信されているか確認できます。

### Gemini APIキー

Gemini APIキーはローカルのLiteDBに保存されます。

![api_key_setup](https://github.com/user-attachments/assets/b64ae501-2f5b-4205-9d2d-a2c12ceae53b)

## 実装上の工夫と課題

### 通知タイミングの制御

通知の頻度と時間帯を制御する仕組みを実装し、ユーザーが設定できるようにしています。特に、日をまたいだ通知設定（例: 21:00〜翌3:00）の処理には工夫が必要でした。


```csharp
// 日またぎの時間設定に対応する時間帯チェックメソッド
private bool IsWithinNotificationTimeRange(TimeSpan currentTime, TimeSpan startTime, TimeSpan endTime)
{
    // 開始時間が終了時間より後の場合（例：21:00-03:00）は日をまたぐ設定
    if (startTime > endTime)
    {
        // 開始時間から翌日の終了時間までが対象（例：21:00-23:59、00:00-03:00）
        return currentTime >= startTime || currentTime <= endTime;
    }
    else
    {
        // 通常の時間帯（例：09:00-18:00）
        return currentTime >= startTime && currentTime <= endTime;
    }
}
```

### 通知スケジューリングの仕組み

```mermaid
stateDiagram-v2
    [*] --> 待機状態
    待機状態 --> 通知時間判定: タイマーイベント
    通知時間判定 --> 通知表示: 通知時間かつ通知時間帯内
    通知時間判定 --> 次回通知予約: 通知時間外または通知時間帯外
    通知表示 --> 次回通知予約: 通知完了
    次回通知予約 --> 待機状態: タイマー設定
    
    通知表示 --> ユーザー応答待ち: ユーザーとの対話開始
    ユーザー応答待ち --> 応答生成: ユーザー入力
    応答生成 --> ユーザー応答待ち: AI応答表示
    ユーザー応答待ち --> 日記データ保存: 会話終了
    日記データ保存 --> 次回通知予約: 保存完了
    
    state 通知時間判定 {
        [*] --> 時刻確認
        時刻確認 --> 時間帯確認: 通知時刻に到達
        時間帯確認 --> [*]: 判定完了
    }
    
    state 次回通知予約 {
        [*] --> 頻度計算
        頻度計算 --> 日時計算: 設定から読込
        日時計算 --> 例外確認: 基本計算
        例外確認 --> 時間帯チェック: 例外なし
        時間帯チェック --> タイマー設定: 時間帯内
        時間帯チェック --> 翌日開始時間設定: 時間帯外
        翌日開始時間設定 --> タイマー設定
        タイマー設定 --> [*]
    }
```

### 多重起動防止の実装

システムに常駐するアプリケーションとして、多重起動を防止する仕組みを実装しています。ミューテックスを使用し、すでに起動している場合は通知を表示して終了します。

```csharp
private void InitializeAppMutex()
{
    try
    {
        // グローバルミューテックスの作成を試行
        var mutexSecurity = new MutexSecurity();
        var sid = new SecurityIdentifier(WellKnownSidType.WorldSid, null);
        mutexSecurity.AddAccessRule(
            new MutexAccessRule(sid, MutexRights.FullControl, AccessControlType.Allow));

        // 既存のミューテックスを開く、または新しいミューテックスを作成
        _appMutex = new Mutex(false, MutexName, out bool createdNew, mutexSecurity);

        // ミューテックスの取得を試みる（タイムアウト5秒）
        _ownsMutex = _appMutex.WaitOne(TimeSpan.FromSeconds(5), false);

        if (!_ownsMutex)
        {
            // 既に実行中の場合の処理
            // ...
        }
    }
    catch (Exception ex)
    {
        // エラー処理
        // ...
    }
}
```

## まとめ

「日記作成アシスタント」は、WPFとLLMを組み合わせた新しいタイプのデスクトップアシスタントです。既存のチャットAIとは一線を画す、キャラクター性を持ったアシスタントが特徴で、日記習慣がない人でも自然に一日を振り返ることができます。

### オープンソースとしての展開

本アプリケーションはGitHubでオープンソースとして公開しています：
https://github.com/kame404/DiaryAssistant

ぜひフィードバックをお待ちしています。バグ報告や機能リクエストはGitHubのIssueから行えます。

## おわりに

WPFという「枯れた」技術と最先端のLLM技術を組み合わせることで、リソース効率が高く、かつ魅力的なアプリケーションを開発することができました。特に、プロンプトエンジニアリングによって、AIに「キャラクター性」を持たせる方法は、今後のAIアプリケーション開発において参考になるかと思います。

動的なプロンプト生成と複数の特化モードの実装によって、AIの能力を最大限に引き出しながらも、メモリ使用量わずか20MBという軽量性を維持できたことは、アプリケーション設計として共有したいポイントです。

皆さんも、ローカル環境を活かした軽量なAIアプリケーションの開発にチャレンジしてみてはいかがでしょうか？