Files
playchat/frontend/src/components/SettingsPanel.vue
T
colcaroliandClaude Sonnet 5 d207537b53 Add GUI settings panel for API keys, model presets, and embedding model
- Add a password-protected /api/settings endpoint (HTTP Basic auth,
  backend/app/core/auth.py) backed by a DB-stored allowlist of
  connection settings (OPENAI_API_KEY, ANTHROPIC_API_KEY, AWS creds,
  OLLAMA_API_BASE, TAVILY_API_KEY, LOCAL_KB_EMBEDDING_MODEL). Saved
  values override .env and apply immediately via os.environ, so
  Lightsail deployments no longer need SSH + `.env` edits to rotate
  keys. Values are always returned masked to the frontend.
- Replace ModelSelector.vue's hardcoded model list with a DB-backed
  /api/models CRUD API (read is public, writes reuse the settings
  password) so the chat model dropdown can be edited from the GUI;
  seed it once with the previous hardcoded presets on first boot.
- Fix local_knowledge.py reading LOCAL_KB_EMBEDDING_MODEL once at
  import time instead of per-call, which would have silently ignored
  GUI/DB overrides for the embedding model.
- Add SettingsPanel.vue (API keys + model management UI) and document
  the new SETTINGS_PASSWORD env var in .env.example and the Lightsail
  deploy guide.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-08-18 01:27:08 +09:00

196 lines
6.7 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, reactive, onMounted, watch } from 'vue'
import { useSettingsStore } from '../stores/settings'
const store = useSettingsStore()
const passwordInput = ref('')
const drafts = reactive<Record<string, string>>({})
const presetDrafts = reactive<Record<number, { label: string; value: string }>>({})
const newLabel = ref('')
const newValue = ref('')
watch(
() => store.modelPresets,
(presets) => {
for (const key of Object.keys(presetDrafts)) delete presetDrafts[Number(key)]
for (const p of presets) presetDrafts[p.id] = { label: p.label, value: p.value }
},
{ immediate: true },
)
async function saveModelPreset(id: number) {
const draft = presetDrafts[id]
if (!draft || !draft.label || !draft.value) return
await store.editModelPreset(id, draft.label, draft.value)
}
async function addModelPreset() {
if (!newLabel.value || !newValue.value) return
const ok = await store.addModelPreset(newLabel.value, newValue.value)
if (ok) {
newLabel.value = ''
newValue.value = ''
}
}
const KEY_LABELS: Record<string, string> = {
OPENAI_API_KEY: 'OpenAI APIキー',
ANTHROPIC_API_KEY: 'Anthropic APIキー',
AWS_ACCESS_KEY_ID: 'AWS Access Key ID',
AWS_SECRET_ACCESS_KEY: 'AWS Secret Access Key',
AWS_SESSION_TOKEN: 'AWS Session Token',
AWS_BEARER_TOKEN_BEDROCK: 'AWS Bedrock Bearer Token',
AWS_REGION_NAME: 'AWS Region',
OLLAMA_API_BASE: 'Ollama API Base URL',
TAVILY_API_KEY: 'Tavily APIキー',
LOCAL_KB_EMBEDDING_MODEL: '埋め込みモデル(ローカルナレッジベース検索用)',
}
// 秘匿情報ではないため、マスクせず通常のテキスト入力として表示するキー
const PLAIN_TEXT_KEYS = new Set(['LOCAL_KB_EMBEDDING_MODEL', 'OLLAMA_API_BASE', 'AWS_REGION_NAME'])
async function submitPassword() {
const ok = await store.unlock(passwordInput.value)
if (ok) passwordInput.value = ''
}
async function saveAll() {
const values: Record<string, string> = {}
for (const [key, value] of Object.entries(drafts)) {
if (value) values[key] = value
}
if (Object.keys(values).length === 0) return
const ok = await store.save(values)
if (ok) {
for (const key of Object.keys(drafts)) drafts[key] = ''
}
}
async function clearOne(key: string) {
await store.clear(key)
}
onMounted(() => {
store.tryAutoUnlock()
store.loadModelPresets()
})
</script>
<template>
<div class="settings-panel">
<div class="toolbar">
<span class="title">API接続設定</span>
<button v-if="store.unlocked" class="lock-btn" @click="store.lock()">ロック</button>
</div>
<form v-if="!store.unlocked" class="login" @submit.prevent="submitPassword">
<p class="hint">APIキーを表示・変更するにはパスワードが必要です。</p>
<input
v-model="passwordInput"
type="password"
placeholder="パスワード"
autocomplete="current-password"
/>
<button type="submit" :disabled="store.loading || !passwordInput">
{{ store.loading ? '確認中...' : 'ロック解除' }}
</button>
<p v-if="store.error" class="error">{{ store.error }}</p>
</form>
<div v-else class="entries">
<p v-if="store.error" class="error">{{ store.error }}</p>
<div v-for="entry in store.entries" :key="entry.key" class="entry">
<label>{{ KEY_LABELS[entry.key] ?? entry.key }}</label>
<div class="row">
<input
v-model="drafts[entry.key]"
:type="PLAIN_TEXT_KEYS.has(entry.key) ? 'text' : 'password'"
:placeholder="entry.is_set ? `設定済み ${entry.masked}` : '未設定'"
autocomplete="off"
/>
<button
v-if="entry.overridden"
class="clear-btn"
title="GUIで設定した値をクリアして.envの値に戻す"
@click="clearOne(entry.key)"
>
クリア
</button>
</div>
</div>
<button class="save-btn" :disabled="store.loading" @click="saveAll">
{{ store.loading ? '保存中...' : '保存' }}
</button>
<hr />
<h3 class="section-title">モデル管理</h3>
<div v-for="p in store.modelPresets" :key="p.id" class="preset-row">
<input v-model="presetDrafts[p.id].label" placeholder="表示名" />
<input v-model="presetDrafts[p.id].value" placeholder="モデル文字列" />
<button class="mini-btn" @click="saveModelPreset(p.id)">保存</button>
<button class="mini-btn danger" @click="store.removeModelPreset(p.id)">削除</button>
</div>
<div class="preset-row new-preset">
<input v-model="newLabel" placeholder="表示名(例: GPT-5.5)" />
<input v-model="newValue" placeholder="モデル文字列(例: gpt-5.5)" />
<button class="mini-btn" @click="addModelPreset">追加</button>
</div>
</div>
</div>
</template>
<style scoped>
.settings-panel {
display: flex;
flex-direction: column;
height: 100%;
overflow-y: auto;
background: #111;
}
.toolbar {
display: flex; align-items: center; justify-content: space-between;
padding: 10px 12px; border-bottom: 1px solid #2a2a2a; flex-shrink: 0;
}
.title { font-size: 13px; font-weight: 600; color: #aaa; }
.lock-btn {
background: none; border: 1px solid #444; color: #aaa;
border-radius: 5px; padding: 2px 10px; font-size: 12px; cursor: pointer;
}
.login { padding: 16px; display: flex; flex-direction: column; gap: 10px; }
.hint { font-size: 12px; color: #888; }
.error { font-size: 12px; color: #f87171; }
input {
background: #2a2a2a; color: #eee; border: 1px solid #444;
border-radius: 6px; padding: 6px 10px; font-size: 13px; width: 100%;
}
button {
background: #1d3557; color: #93c5fd; border: 1px solid #2563eb;
border-radius: 6px; padding: 6px 10px; font-size: 13px; cursor: pointer;
}
button:disabled { opacity: 0.5; cursor: default; }
.entries { padding: 12px; display: flex; flex-direction: column; gap: 14px; }
.entry { display: flex; flex-direction: column; gap: 4px; }
.entry label { font-size: 12px; color: #888; }
.row { display: flex; gap: 6px; }
.row input { flex: 1; }
.clear-btn {
background: none; border: 1px solid #444; color: #aaa;
font-size: 12px; padding: 4px 8px; white-space: nowrap;
}
.save-btn { align-self: flex-start; padding: 6px 18px; }
hr { border: none; border-top: 1px solid #2a2a2a; margin: 4px 0; }
.section-title { font-size: 12px; color: #888; text-transform: uppercase; }
.preset-row { display: flex; gap: 6px; align-items: center; }
.preset-row input { flex: 1; min-width: 0; }
.mini-btn {
background: none; border: 1px solid #444; color: #aaa;
font-size: 12px; padding: 6px 10px; white-space: nowrap;
}
.mini-btn.danger { border-color: #7f1d1d; color: #f87171; }
.new-preset { padding-top: 4px; }
</style>