Files
playchat/frontend/src/components/SettingsPanel.vue
T
colcaroliandClaude Opus 5 1b88d23f21 Set the OpenAI-compatible endpoint from the settings panel
Pointing OPENAI_BASE_URL at a local llama-server or Ollama sends every gpt-*
call there instead of to OpenAI, and litellm reports that as a bare
"Connection Error" — which reads like a network problem even though the host
can reach api.openai.com fine. Deploying a .env that still carries the local
endpoint reproduces it on the server, with nothing in the message naming the
culprit.

Add OPENAI_BASE_URL and OPENAI_API_BASE to the settings allowlist so the
endpoint can be corrected without editing .env and recreating the container.
Both are listed because litellm reads OPENAI_API_BASE while the OpenAI SDK
reads OPENAI_BASE_URL, so fixing one can leave the other in effect. Neither is
a secret, so the panel shows the current value.

No cache invalidation is needed alongside this: litellm keys its client cache
on the endpoint, so a changed value takes effect on the next call.

Document the trap where it will be read — a commented template in .env.example
and a note in the Lightsail deploy guide about carrying a local .env over.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-23 23:12:34 +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キー',
OPENAI_BASE_URL: 'OpenAI 互換エンドポイント (OPENAI_BASE_URL)',
OPENAI_API_BASE: 'OpenAI 互換エンドポイント (OPENAI_API_BASE)',
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: '埋め込みモデル(ローカルナレッジベース検索用)',
SEARCH_SUMMARY_MODEL: '要約モデル(Web検索結果のまとめ用)',
}
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="entry.secret ? 'password' : 'text'"
:placeholder="entry.is_set ? `現在: ${entry.display_value}` : '未設定'"
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>