4113 words
21 minutes
AIRIプロジェクトに参加した話

自分のコードが、知らない誰かに届く。
それだけで、ちょっと誇らしい。こんにちは、YAMAです。
今回は、僕が初めてOSS(オープンソースソフトウェア)に参加した時の体験を、備忘録がてら残しておこうと思います。

Proj/airiとの出会い#

ある日、Githubでmoeru-aiというプロジェクトを何気なくクリックしたのが始まりでした。
機械学習やチャットボット技術、さらにはWeb技術も組み込まれていて、**「これ…まさに全部盛りじゃん!」**と好奇心が爆発。
さらには、Vtuberのような女の子と会話できるという要素もあり、これは日本人としての萌えの心に火がつきました。
でも、当時の僕はGitHubのIssueにコメントを残すのも緊張するような初心者で、Pull Requestなんて夢のまた夢。
「でも、やってみないと始まらない!」と勇気を出して、Issueを一つ書いてみました。

思ってたより、全然むずい#

最初のうちは、プロジェクトの構成が全くわからない。
ファイルの量、ライブラリの数、処理の流れ、すべてが桁違い。
新たな言語Vue.jsや、AIの学習モデルなど、初めて触るものばかり。
他にも言語の壁もあり、英語のドキュメントを読むのも一苦労。
でも、不思議と「やめたい」とは思わなかった。
むしろ、気づいたら夢中でコードを読んでいたし、IssueやPRのコメント欄を追いかけては、「こんなふうにやりとりするんだな」と学びまくってました。

取り組んだIssue #254の詳細#

Issue #254: 初回起動時のAPI設定プロンプト機能#

私が取り組んだのは、Issue #254「feature request: prompt user with API key and Base URL setup on first launch」でした。

問題の背景#

AIRIを初めて使うユーザーが、APIキーやBase URLの設定方法がわからず、アプリケーションを正常に動作させることができないという課題がありました。

要求された機能#

  • アプリケーション初回起動時、またはAPIキー・Base URLが未設定の場合にダイアログを表示
  • ユーザーに必須設定の入力を促す
  • 「後で設定」ボタンでスキップも可能
  • 入力された値は即座にプロバイダー設定に反映
  • 設定済みの場合はダイアログを表示しない
  • 設定画面からいつでも変更可能

技術的な要求#

  • AIRIのデザインシステムに準拠したUI
  • 入力バリデーションの実装
  • 多言語対応(i18n)
  • デスクトップ・Web両アプリケーションでの一貫した体験

実装への挑戦:Vue.jsとの格闘記#

「ダイアログ作るだけでしょ?」…甘かった#

最初は軽い気持ちでした。
「APIキー入力のダイアログを作るだけでしょ?」って。
でも、実際に手を動かし始めると、これが思った以上に奥が深い。

何がそんなに難しいのか?#

  • Vue.jsのコンポーネント設計の仕方がわからない
  • AIRIプロジェクトの既存のデザインシステムに合わせる必要がある
  • ただ表示するだけじゃダメ、ユーザー体験を考えた設計が必要
  • 多言語対応も必須

でも、「やってみないと始まらない!」ということで、まずは基本的なダイアログから作り始めました。

FirstTimeSetupDialog.vue:メインコンポーネントの設計#

まず、ダイアログの骨組みから作りました:

<!-- FirstTimeSetupDialog.vue -->
<template>
  <Dialog v-if="shouldShowDialog" :open="shouldShowDialog">
    <DialogContent class="max-w-2xl">
      <!-- ダイアログのヘッダー部分 -->
      <DialogHeader>
        <DialogTitle>{{ $t('firstTimeSetup.title') }}</DialogTitle>
        <DialogDescription>
          {{ $t('firstTimeSetup.description') }}
        </DialogDescription>
      </DialogHeader>

      <!-- プロバイダー選択エリア -->
      <div class="space-y-4">
        <Label>{{ $t('firstTimeSetup.selectProvider') }}</Label>
        <RadioGroup v-model="selectedProvider" class="space-y-2">
          <RadioCardDetail
            v-for="provider in availableProviders"
            :key="provider.value"
            :value="provider.value"
            :title="provider.title"
            :description="provider.description"
          />
        </RadioGroup>
      </div>

この部分の機能説明#

  • v-if="shouldShowDialog":条件付きでダイアログを表示する制御
  • {{ $t('...') }}:多言語対応のための翻訳キー
  • RadioCardDetail:AIRIのデザインシステムのカード型選択UI
  • v-for:利用可能なプロバイダー(OpenAI、Claude等)をループ表示
      <!-- API設定入力フォーム -->
      <div class="space-y-4">
        <div>
          <Label for="apiKey">{{ $t('firstTimeSetup.apiKey') }}</Label>
          <Input
            id="apiKey"
            v-model="formData.apiKey"
            type="password"
            :placeholder="$t('firstTimeSetup.apiKeyPlaceholder')"
            required
          />
        </div>
        
        <div>
          <Label for="baseUrl">{{ $t('firstTimeSetup.baseUrl') }}</Label>
          <Input
            id="baseUrl"
            v-model="formData.baseUrl"
            :placeholder="$t('firstTimeSetup.baseUrlHelp')"
            required
          />
        </div>
      </div>

フォーム部分の機能#

  • type="password":APIキーを隠して表示(セキュリティ対策)
  • v-model:Vue.jsの双方向データバインディングで入力値を管理
  • required:必須入力項目の指定
  • プレースホルダーも多言語対応
      <!-- リアルタイムバリデーション結果 -->
      <div v-if="validationResult" class="text-sm">
        <div v-if="validationResult.success" class="text-green-600">
          {{ $t('firstTimeSetup.validationSuccess') }}
        </div>
        <div v-else class="text-red-600">
          {{ $t('firstTimeSetup.validationFailed') }}
        </div>
      </div>

      <!-- ボタンエリア -->
      <DialogFooter>
        <Button @click="skipSetup" variant="secondary">
          {{ $t('firstTimeSetup.skipForNow') }}
        </Button>
        <Button @click="saveSettings" :disabled="!isFormValid">
          {{ $t('firstTimeSetup.saveAndContinue') }}
        </Button>
      </DialogFooter>
    </DialogContent>
  </Dialog>
</template>

バリデーションとボタンの機能#

  • 入力内容をリアルタイムでチェックして成功・失敗を色分け表示
  • 「スキップ」ボタン:後で設定したいユーザー向け
  • 「保存」ボタン:フォームが有効な時のみ押せるように制御

TypeScriptロジック:状態管理とバリデーション#

<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { useDebounceFn } from '@vueuse/core'
import { useFirstTimeSetupStore } from '@/stores/onboarding-setup'
import { useProviderStore } from '@/stores/provider'

// 必要なストア(状態管理)をインポート
const firstTimeSetupStore = useFirstTimeSetupStore()
const providerStore = useProviderStore()

// リアクティブな変数の定義
const selectedProvider = ref('openai')  // 選択されたプロバイダー
const formData = ref({
  apiKey: '',    // 入力されたAPIキー
  baseUrl: ''    // 入力されたBase URL
})
const validationResult = ref(null)  // バリデーション結果

// 計算されたプロパティ(自動で再計算される値)
const shouldShowDialog = computed(() => 
  firstTimeSetupStore.shouldShowDialog
)

const isFormValid = computed(() => 
  formData.value.apiKey.length > 0 && formData.value.baseUrl.length > 0
)

このコードの役割#

  • ref():Vue.jsでリアクティブな変数を作成
  • computed():他の値に依存して自動計算される値
  • ストアからデータを取得して、コンポーネント内で使用
// 利用可能なプロバイダーの定義
const availableProviders = [
  {
    value: 'openai',
    title: 'OpenAI',
    description: 'GPT-3.5, GPT-4, etc.'
  },
  {
    value: 'anthropic', 
    title: 'Anthropic',
    description: 'Claude models'
  },
  {
    value: 'custom',
    title: 'Custom Provider',
    description: 'Your own API endpoint'
  }
]

// デバウンス機能付きバリデーション(連続入力時の負荷軽減)
const debouncedValidation = useDebounceFn(async () => {
  if (!isFormValid.value) return
  
  try {
    // APIに実際に接続してテスト
    const result = await testApiConnection(formData.value)
    validationResult.value = { success: true }
  } catch (error) {
    validationResult.value = { 
      success: false, 
      error: error.message 
    }
  }
}, 1000) // 1秒後に実行

デバウンス機能の説明#

  • ユーザーが入力するたびにAPIテストするのは負荷が大きい
  • 1秒間入力が止まったらテストを実行する仕組み
  • useDebounceFn:VueUseライブラリの便利機能
// 入力値の変更を監視してバリデーション実行
watch([() => formData.value.apiKey, () => formData.value.baseUrl], () => {
  validationResult.value = null  // 結果をリセット
  debouncedValidation()          // デバウンス付きでバリデーション実行
})

// 設定保存の処理
function saveSettings() {
  // プロバイダーストアに設定を保存
  providerStore.updateProvider({
    type: selectedProvider.value,
    apiKey: formData.value.apiKey,
    baseUrl: formData.value.baseUrl
  })
  
  // セットアップ完了フラグを設定
  firstTimeSetupStore.completeSetup()
}

// スキップ処理
function skipSetup() {
  firstTimeSetupStore.skipSetup()
}

// API接続テスト関数
async function testApiConnection(config) {
  const response = await fetch(`${config.baseUrl}/test`, {
    headers: {
      'Authorization': `Bearer ${config.apiKey}`
    }
  })
  
  if (!response.ok) {
    throw new Error('API connection failed')
  }
  
  return response.json()
}
</script>

関数の機能説明#

  • watch():特定の値の変更を監視して処理を実行
  • saveSettings():フォームの内容をストアに保存
  • skipSetup():ダイアログを閉じてスキップ
  • testApiConnection():入力されたAPI情報で実際に接続テスト

状態管理の仕組み:Piniaストアの実装#

次に困ったのが「いつダイアログを表示するか?」の判定ロジック。
単純に「初回起動時」と言っても、実際には複雑な条件があります。

考慮すべき条件#

  • 本当に初回起動なのか?
  • APIキーは設定済みか?
  • ユーザーがスキップしたことがあるか?
  • 設定完了済みなのか?

これを管理するため、Pinia(Vue.jsの状態管理ライブラリ)を使いました:

// stores/onboarding-setup.ts
import { defineStore } from 'pinia'
import { useProviderStore } from './provider'

export const useFirstTimeSetupStore = defineStore('onboarding-setup', () => {
  // リアクティブな状態変数
  const isCompleted = ref(false)    // セットアップ完了フラグ
  const isSkipped = ref(false)      // スキップフラグ
  
  // ダイアログを表示するかどうかの判定ロジック
  const shouldShowDialog = computed(() => {
    const providerStore = useProviderStore()
    
    // 既に完了またはスキップされている場合は表示しない
    if (isCompleted.value || isSkipped.value) {
      return false
    }
    
    // 必要な設定が既に存在する場合は表示しない
    if (providerStore.hasEssentialConfiguration) {
      return false
    }
    
    // 上記に該当しない場合は表示
    return true
  })

この判定ロジックの意味#

  • セットアップが完了済み → ダイアログ表示しない
  • ユーザーがスキップ済み → ダイアログ表示しない
  • 既にAPI設定が存在 → ダイアログ表示しない
  • どの条件にも当てはまらない → ダイアログ表示
  // セットアップ完了処理
  function markSetupCompleted() {
    shouldShowSetup.value = false
    // LocalStorageに永続保存(次回起動時も覚えている)
    localStorage.setItem('airi-setup-completed', 'true')
  }
  
  // スキップ処理
  function markSetupSkipped() {
    shouldShowSetup.value = false
    // SessionStorageに一時保存(ブラウザ閉じたら忘れる)
    sessionStorage.setItem('airi-setup-skipped', 'true')
  }
  
  // セットアップチェックの初期化
  function initializeSetupCheck() {
    // 保存された状態を読み込み
    const isCompleted = localStorage.getItem('airi-setup-completed') === 'true'
    const isSkipped = sessionStorage.getItem('airi-setup-skipped') === 'true'
    
    // 完了していない、かつスキップもしていない場合に表示
    shouldShowSetup.value = !isCompleted && !isSkipped
  }

保存場所の使い分け#

  • localStorage:ブラウザを閉じても残る(完了状態用)
  • sessionStorage:ブラウザを閉じると消える(スキップ状態用)

この使い分けにより、「スキップした場合は次回起動時に再表示」という動作を実現しています。

  // 外部に公開する関数・値を返す
  return {
    shouldShowSetup: readonly(shouldShowSetup),     // 読み取り専用で公開
    markSetupCompleted,
    markSetupSkipped,
    initializeSetupCheck
  }
})

Piniaストアの利点#

  • 複数のコンポーネントから同じ状態を参照できる
  • 状態の変更を一箇所で管理できる
  • TypeScriptとの相性が良い

多言語対応:世界中のユーザーのために#

AIRIは世界中で使われているプロジェクトなので、多言語対応は必須でした。
最初は「翻訳なんて簡単でしょ?」と思っていましたが、これがまた奥が深い。

多言語対応の課題#

  • 単純な翻訳だけじゃダメ
  • UIの文字数が変わるとレイアウトが崩れる
  • 文化的な違いも考慮する必要がある

まず、翻訳キーの設計から始めました:

// locales/en.json (英語版)
{
  "firstTimeSetup": {
    "title": "Welcome to AIRI!",
    "description": "To get started, please configure your AI provider settings.",
    "selectProvider": "Select AI Provider",
    "apiKey": "API Key", 
    "apiKeyPlaceholder": "Enter your API key",
    "baseUrl": "Base URL",
    "baseUrlPlaceholder": "Enter the API base URL",
    "validationSuccess": "✅ Connection successful!",
    "validationFailed": "❌ Connection failed. Please check your settings.",
    "save": "Save Settings",
    "skipNow": "Skip for now"
  }
}

翻訳キーの命名規則#

  • firstTimeSetup.title:どの機能の何の項目かが分かりやすい
  • 階層構造で管理して、関連する翻訳をまとめる
  • プレースホルダーや成功・失敗メッセージも含める
// locales/zh.json (中国語版)
{
  "firstTimeSetup": {
    "title": "欢迎使用 AIRI!",
    "description": "首次使用请配置您的AI服务商设置。",
    "selectProvider": "选择AI服务商", 
    "apiKey": "API密钥",
    "apiKeyPlaceholder": "请输入API密钥",
    "baseUrl": "基础URL",
    "baseUrlPlaceholder": "请输入API基础URL",
    "validationSuccess": "✅ 连接成功!",
    "validationFailed": "❌ 连接失败,请检查设置。",
    "save": "保存设置",
    "skipNow": "稍后配置"
  }
}

翻訳時に気をつけたポイント#

  • 絵文字(✅❌)は視覚的で言語関係なく理解しやすい
  • 中国語の方が文字数が少なくなりがち → レイアウト調整が必要
  • 「稍后配置」vs「跳过」→ ユーザーにとってより親しみやすい表現を選択

コンポーネント内での多言語対応#

<!-- テンプレート内での翻訳キー使用例 -->
<template>
  <DialogTitle>{{ $t('firstTimeSetup.title') }}</DialogTitle>
  <DialogDescription>
    {{ $t('firstTimeSetup.description') }}
  </DialogDescription>
  
  <!-- 動的な翻訳(変数を含む場合) -->
  <div v-if="selectedProvider">
    {{ $t('firstTimeSetup.selectedProviderText', { provider: selectedProvider }) }}
  </div>
</template>

テンプレート内での翻訳機能#

  • $t():Vue I18nの翻訳関数
  • 単純な文字列置換だけでなく、変数埋め込みも可能
  • リアルタイムで言語切り替えが可能

そして、レビューを受ける:コードの「人間ドック」#

Pull Requestを出した後が、この経験で一番学びの多い時間でした。
プロジェクトのメンテナーから来たレビュー、これがもう**「目から鱗」**の連続。

Gemini Code Assistとの出会い#

まず驚いたのが、AIによるコードレビューがあったこと。
「Gemini Code Assist」がコードを自動でチェックしてくれるんです。

AIレビューで指摘されたこと#

  • 変数名の一貫性について
  • エラーハンドリングの改善提案
  • パフォーマンス最適化のアドバイス
  • TypeScriptの型定義をより厳密に

「AIってここまで見てくれるのか…!」

正直、人間よりも細かいところまでチェックしてくれて、しかも24時間いつでも対応。
これは革命的だと思いました。

人間のレビューアーからの学び#

でも、やっぱり人間のレビューには違う良さがありました。
プロジェクトメンテナーの@nekomeowwwさんからのコメント:

「この実装、機能的には動くけど、ユーザー体験的にはどう思う?」
「ここのコンポーネント名、もう少しAIRIの命名規則に合わせてみない?」
「コード的には問題ないけど、将来の拡張性も考えてみよう」

人間レビューの特徴#

  • ユーザー体験やプロジェクトの哲学まで考慮してくれる
  • コードの「なぜ」の部分まで深掘りしてくれる
  • 「動く」だけじゃなく「美しい」コードを目指すアドバイス

レビュー修正の繰り返し#

最初は「これ、全然ダメだな…」と落ち込むこともありました。
でも、修正→レビュー→また修正の繰り返しで、確実にコードが良くなっていくのを実感。

修正の例#

// 修正前(動くけどイマイチ)
function validateForm() {
  if (apiKey.value.length > 0 && baseUrl.value.length > 0) {
    return true;
  }
  return false;
}

// 修正後(より明確で拡張しやすい)
const isFormValid = computed(() => {
  const hasApiKey = apiKey.value.trim().length > 0
  const hasBaseUrl = baseUrl.value.trim().length > 0
  const isValidUrl = isValidHttpUrl(baseUrl.value)
  
  return hasApiKey && hasBaseUrl && isValidUrl
})

修正のポイント#

  • バリデーションロジックをより厳密に
  • URLの形式チェックも追加
  • 空白文字の除去(.trim())も考慮
  • 計算プロパティを使ってリアクティブに

コードレビューで学んだこと#

レビューを通じて、自分のコードがどう改善できるかを学ぶことができました。

技術的な学び#

  • Vue.jsのベストプラクティス
  • TypeScriptの型安全性の向上
  • パフォーマンスを意識したコード
  • AIRIプロジェクトの設計思想

開発者としての学び

  • コードは「動けばいい」じゃない
  • ユーザー体験を常に意識する
  • 将来の拡張性も考慮する
  • チームでの開発はコミュニケーションが9割

達成感と、その先にあるもの:「Merged」の瞬間#

最終的に、自分で書いたコードがMergeされたときの気持ち。
これは、もう文字では表現しきれません。

画面に映る自分のIDと「Merged」の文字。
GitHub上で「#259 merged」の表示。
それだけなのに、心が震えた。

「本当にあのプロジェクトに貢献できたのか!?」#

そんな信じられないような嬉しさと同時に、新たな現実も見えてきました。

上には上がいるという現実#

コードがマージされた後、他のコントリビューターのコードを改めて見返してみました。
そこで目にしたのは――**「これはレベチだな…」**と、感動を超えて尊敬に至るレベルの技術力。

他の開発者のコードで学んだこと#

  • コードの見通しの良さが桁違い
  • 命名の丁寧さ(変数名を見ただけで何をするかわかる)
  • リファクタリングの美しさ(無駄が一切ない)
  • エラーハンドリングの徹底ぶり
  • ドキュメンテーションの充実度

「自分のコードがマージされて嬉しい!」

「でも、まだまだ学ぶことがいっぱいある!」

この気づきが、さらなるモチベーションにつながりました。

OSSって「学びの宝庫」だった#

この経験を通して、OSSは「公開されたコードの集合体」なんかじゃなく、世界中の技術者とつながる場なんだって実感しました。

  • 自分の技術を試せる場所
  • 世界の技術力に触れられる場所
  • 一緒に何かを作る喜びを味わえる場所

その全部が、ここにある。

最後に#

OSSに初めて参加したことで、僕の中の「開発者」という意識が大きく変わりました。
ただコードを書くだけじゃなくて、「そのコードが誰かの手に渡る」という実感が生まれると、モチベーションの質が変わります。
これからも挑戦していきたい。
そう思える出会いでした。
もし、この記事を読んでいるあなたもOSSに興味があるなら、ぜひ一歩踏み出してみてください。
きっと、あなたの世界も広がるはずです。

以上、YAMAでした。

参考リンク#