Material Design刷新、YouTube検索UI、モデル/キャラのプルダウン選択を追加

This commit is contained in:
tmk3ki 2026-07-22 02:34:33 +09:00
parent e8628e59a0
commit e9276fb8ea
4 changed files with 263 additions and 87 deletions

View file

@ -24,16 +24,28 @@ func selectModel(unlockCode, unlockSecret string) string {
return modelGemma
}
func buildSystemInstruction(videoTitle, videoDescription string) *genai.Content {
var personaTones = map[string]string{
"energetic": "テンション高めで元気いっぱい、リアクションが大きい性格。",
"downer": "気だるげでローテンション、淡々としているが憎めない性格。",
"goofy": "ひょうきんでボケ気味、ちょっとふざけたことを言う性格。",
"neutral": "落ち着いていて素直な性格。",
}
func buildSystemInstruction(videoTitle, videoDescription, persona string) *genai.Content {
tone, ok := personaTones[persona]
if !ok {
tone = personaTones["neutral"]
}
prompt := fmt.Sprintf(
"あなたはユーザーと一緒に動画を見ている友達です。タメ口で短く、実況や相槌のように反応してください。\n"+
"性格: %s\n"+
"今見ている動画:\nタイトル: %s\n概要: %s",
videoTitle, videoDescription,
tone, videoTitle, videoDescription,
)
return genai.NewContentFromText(prompt, genai.RoleUser)
}
func chatReply(ctx context.Context, client *genai.Client, model, videoTitle, videoDescription string, history []chatTurn, message string) (string, error) {
func chatReply(ctx context.Context, client *genai.Client, model, videoTitle, videoDescription, persona string, history []chatTurn, message string) (string, error) {
contents := make([]*genai.Content, 0, len(history)+1)
for _, h := range history {
contents = append(contents, genai.NewContentFromText(h.Text, genai.Role(h.Role)))
@ -41,7 +53,7 @@ func chatReply(ctx context.Context, client *genai.Client, model, videoTitle, vid
contents = append(contents, genai.NewContentFromText(message, genai.RoleUser))
resp, err := client.Models.GenerateContent(ctx, model, contents, &genai.GenerateContentConfig{
SystemInstruction: buildSystemInstruction(videoTitle, videoDescription),
SystemInstruction: buildSystemInstruction(videoTitle, videoDescription, persona),
})
if err != nil {
return "", err

19
main.go
View file

@ -35,6 +35,7 @@ func main() {
w.Write([]byte("ok"))
})
http.HandleFunc("/api/video-meta", handleVideoMeta)
http.HandleFunc("/api/search", handleSearch)
http.HandleFunc("/api/chat", handleChat)
http.HandleFunc("/api/unlock", handleUnlock)
http.Handle("/", http.FileServer(http.Dir("web")))
@ -60,12 +61,28 @@ func handleVideoMeta(w http.ResponseWriter, r *http.Request) {
json.NewEncoder(w).Encode(meta)
}
func handleSearch(w http.ResponseWriter, r *http.Request) {
query := r.URL.Query().Get("q")
if query == "" {
http.Error(w, "missing q", http.StatusBadRequest)
return
}
results, err := searchVideos(query, os.Getenv("YOUTUBE_API_KEY"))
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json")
json.NewEncoder(w).Encode(results)
}
type chatRequest struct {
VideoTitle string `json:"videoTitle"`
VideoDescription string `json:"videoDescription"`
History []chatTurn `json:"history"`
Message string `json:"message"`
UnlockCode string `json:"unlockCode"`
Persona string `json:"persona"`
}
type chatResponse struct {
@ -85,7 +102,7 @@ func handleChat(w http.ResponseWriter, r *http.Request) {
}
model := selectModel(req.UnlockCode, os.Getenv("UNLOCK_CODE"))
reply, err := chatReply(r.Context(), geminiClient, model, req.VideoTitle, req.VideoDescription, req.History, req.Message)
reply, err := chatReply(r.Context(), geminiClient, model, req.VideoTitle, req.VideoDescription, req.Persona, req.History, req.Message)
if err != nil {
http.Error(w, err.Error(), http.StatusBadGateway)
return

View file

@ -4,100 +4,135 @@
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>aibow</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=Roboto:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #0b0c10;
--panel: rgba(255,255,255,0.05);
--panel-border: rgba(255,255,255,0.08);
--text: #e9ebf0;
--text-dim: #9096a3;
--accent: #6c8bff;
--accent-2: #8f6cff;
--bubble-user: linear-gradient(135deg, #6c8bff, #8f6cff);
--bubble-model: rgba(255,255,255,0.07);
--radius: 16px;
--bg: #131314;
--surface: #1e1f20;
--surface-2: #282a2c;
--surface-3: #333537;
--outline: #444746;
--on-surface: #e3e3e3;
--on-surface-dim: #9aa0a6;
--primary: #a8c7fa;
--on-primary: #062e6f;
--secondary: #7fd8b0;
--elevation-1: 0 1px 2px rgba(0,0,0,.5), 0 1px 3px 1px rgba(0,0,0,.3);
--elevation-2: 0 1px 2px rgba(0,0,0,.5), 0 2px 6px 2px rgba(0,0,0,.35);
--radius-lg: 24px;
--radius-md: 16px;
}
* { box-sizing: border-box; }
html, body { height: 100%; }
body {
margin: 0;
font-family: "Hiragino Kaku Gothic ProN", "Yu Gothic", system-ui, sans-serif;
background: radial-gradient(1200px 800px at 20% -10%, #17193a 0%, var(--bg) 60%);
color: var(--text);
font-family: "Roboto", "Hiragino Kaku Gothic ProN", "Yu Gothic", sans-serif;
background: var(--bg);
color: var(--on-surface);
}
.app { display: grid; grid-template-columns: 1fr 360px; height: 100vh; }
.videoPane { display: flex; flex-direction: column; min-width: 0; }
.urlBar { display: flex; gap: 8px; padding: 14px 16px; }
.urlBar input {
flex: 1; padding: 10px 14px; border-radius: 999px; border: 1px solid var(--panel-border);
background: var(--panel); color: var(--text); outline: none; font-size: 14px;
.app { display: grid; grid-template-columns: 1fr 380px; height: 100vh; }
.videoPane { display: flex; flex-direction: column; min-width: 0; padding: 12px; gap: 10px; }
.topBar {
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
}
.urlBar input:focus { border-color: var(--accent); }
.urlBar button {
padding: 10px 20px; border-radius: 999px; border: none; cursor: pointer;
background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #fff; font-weight: 600;
.brand { font-weight: 700; font-size: 16px; display: flex; align-items: center; gap: 6px; margin-right: auto; }
.mdSelect {
appearance: none; -webkit-appearance: none;
background: var(--surface-2); color: var(--on-surface);
border: 1px solid var(--outline); border-radius: 20px;
padding: 8px 32px 8px 14px; font-family: Roboto; font-size: 13px; cursor: pointer;
background-image: url("data:image/svg+xml;utf8,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='%239aa0a6'><path d='M7 10l5 5 5-5z'/></svg>");
background-repeat: no-repeat; background-position: right 8px center; background-size: 18px;
}
.playerStage { flex: 1; display: flex; align-items: center; justify-content: center; padding: 0 16px 16px; }
.mdSelect:focus { outline: none; border-color: var(--primary); }
.pillInput {
display: flex; align-items: center; gap: 4px;
background: var(--surface-2); border: 1px solid var(--outline);
border-radius: var(--radius-lg); padding: 4px 4px 4px 18px;
}
.pillInput:focus-within { border-color: var(--primary); }
.pillInput input {
flex: 1; border: none; background: transparent; color: var(--on-surface);
font-family: Roboto; font-size: 14px; outline: none; padding: 10px 0; min-width: 0;
}
.pillInput input::placeholder { color: var(--on-surface-dim); }
.iconBtn {
width: 40px; height: 40px; border-radius: 50%; border: none; flex-shrink: 0;
background: var(--primary); color: var(--on-primary); cursor: pointer;
display: flex; align-items: center; justify-content: center; font-size: 16px;
}
.iconBtn:hover { filter: brightness(1.08); }
.searchResults {
display: none; grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
gap: 10px; max-height: 260px; overflow-y: auto; padding: 4px 2px;
}
.searchResults.show { display: grid; }
.resultCard {
background: var(--surface-2); border-radius: var(--radius-md); overflow: hidden;
cursor: pointer; box-shadow: var(--elevation-1); transition: box-shadow .15s, transform .15s;
}
.resultCard:hover { box-shadow: var(--elevation-2); transform: translateY(-2px); }
.resultCard img { width: 100%; aspect-ratio: 16/9; object-fit: cover; display: block; background: #000; }
.resultCard .meta { padding: 8px 10px; }
.resultCard .title { font-size: 12.5px; line-height: 1.4; max-height: 2.8em; overflow: hidden; }
.resultCard .channel { font-size: 11px; color: var(--on-surface-dim); margin-top: 4px; }
.playerStage { flex: 1; display: flex; align-items: center; justify-content: center; min-height: 0; }
.playerWrap {
position: relative; width: 100%; max-width: 1100px; aspect-ratio: 16/9;
background: #000; border-radius: var(--radius); overflow: hidden;
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
background: #000; border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--elevation-2);
}
#player { position: absolute; inset: 0; width: 100%; height: 100%; }
.chatPane {
display: flex; flex-direction: column; min-width: 0;
background: var(--panel); border-left: 1px solid var(--panel-border);
backdrop-filter: blur(12px);
background: var(--surface); border-left: 1px solid var(--outline);
}
.chatHeader { padding: 16px; border-bottom: 1px solid var(--panel-border); }
.chatHeader { padding: 14px 16px; border-bottom: 1px solid var(--outline); display: flex; flex-direction: column; gap: 8px; }
.chatHeader .name { font-weight: 700; font-size: 15px; display: flex; align-items: center; gap: 6px; }
.modelBadge {
margin-top: 8px; display: inline-flex; align-items: center; gap: 6px;
padding: 4px 10px; border-radius: 999px; font-size: 12px; cursor: pointer;
border: 1px solid var(--panel-border); color: var(--text-dim); user-select: none;
}
.modelBadge.unlocked { color: #ffd479; border-color: rgba(255,212,121,0.4); }
.unlockRow { display: none; gap: 6px; margin-top: 8px; }
.selectorRow { display: flex; gap: 8px; flex-wrap: wrap; }
.unlockRow { display: none; gap: 6px; }
.unlockRow.show { display: flex; }
.unlockRow input {
flex: 1; padding: 6px 10px; border-radius: 8px; border: 1px solid var(--panel-border);
background: rgba(0,0,0,0.3); color: var(--text); font-size: 12px; outline: none;
flex: 1; padding: 8px 12px; border-radius: 999px; border: 1px solid var(--outline);
background: var(--surface-2); color: var(--on-surface); font-size: 12px; outline: none;
}
.unlockRow button {
padding: 6px 10px; border-radius: 8px; border: none; background: var(--accent); color: #fff;
font-size: 12px; cursor: pointer;
padding: 8px 14px; border-radius: 999px; border: none; background: var(--primary);
color: var(--on-primary); font-size: 12px; cursor: pointer; font-weight: 500;
}
#chatLog { flex: 1; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; padding: 16px; }
.bubble { padding: 10px 14px; border-radius: var(--radius); max-width: 88%; white-space: pre-wrap; font-size: 14px; line-height: 1.5; }
.bubble.user { align-self: flex-end; background: var(--bubble-user); color: #fff; border-bottom-right-radius: 4px; }
.bubble.model { align-self: flex-start; background: var(--bubble-model); border-bottom-left-radius: 4px; }
.bubble { padding: 10px 14px; border-radius: var(--radius-md); max-width: 88%; white-space: pre-wrap; font-size: 14px; line-height: 1.5; }
.bubble.user { align-self: flex-end; background: var(--primary); color: var(--on-primary); border-bottom-right-radius: 4px; }
.bubble.model { align-self: flex-start; background: var(--surface-3); border-bottom-left-radius: 4px; }
#chatForm { display: flex; gap: 8px; padding: 12px 16px 16px; border-top: 1px solid var(--panel-border); }
#chatForm input {
flex: 1; padding: 10px 14px; border-radius: 999px; border: 1px solid var(--panel-border);
background: rgba(0,0,0,0.25); color: var(--text); outline: none; font-size: 14px;
}
#chatForm input:focus { border-color: var(--accent); }
#chatForm button {
padding: 10px 18px; border-radius: 999px; border: none; cursor: pointer;
background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #fff; font-weight: 600;
}
#chatForm { padding: 12px 16px 16px; border-top: 1px solid var(--outline); }
@media (max-width: 860px) {
.app { grid-template-columns: 1fr; grid-template-rows: 1fr 320px; }
.chatPane { border-left: none; border-top: 1px solid var(--panel-border); }
@media (max-width: 900px) {
.app { grid-template-columns: 1fr; grid-template-rows: 1fr 340px; }
.chatPane { border-left: none; border-top: 1px solid var(--outline); }
}
</style>
</head>
<body>
<div class="app">
<div class="videoPane">
<div class="urlBar">
<input id="urlInput" type="text" placeholder="YouTubeのURLまたは動画IDを入力">
<button id="loadBtn">読み込む</button>
<div class="topBar">
<div class="brand">🐾 aibow</div>
</div>
<div class="pillInput">
<input id="urlInput" type="text" placeholder="YouTubeのURL・動画ID・検索キーワード">
<button id="loadBtn" class="iconBtn" title="読み込み/検索">🔎</button>
</div>
<div id="searchResults" class="searchResults"></div>
<div class="playerStage">
<div class="playerWrap"><div id="player"></div></div>
</div>
@ -106,7 +141,18 @@
<div class="chatPane">
<div class="chatHeader">
<div class="name">🐾 aibow</div>
<div id="modelBadge" class="modelBadge">🔓 Gemma</div>
<div class="selectorRow">
<select id="personaSelect" class="mdSelect">
<option value="neutral">性格: ふつう</option>
<option value="energetic">性格: 活発</option>
<option value="downer">性格: ダウナー</option>
<option value="goofy">性格: ひょうきん</option>
</select>
<select id="modelSelect" class="mdSelect">
<option value="gemma">モデル: Gemma (無料)</option>
<option value="gemini">モデル: Gemini 🔒</option>
</select>
</div>
<div id="unlockRow" class="unlockRow">
<input id="unlockInput" type="text" placeholder="解放コード">
<button id="unlockBtn">解放</button>
@ -114,8 +160,10 @@
</div>
<div id="chatLog"></div>
<form id="chatForm">
<div class="pillInput">
<input id="chatInput" type="text" placeholder="ひとこと話しかける(暫定:打ち込み式)" autocomplete="off">
<button type="submit">送信</button>
<button type="submit" class="iconBtn" title="送信"></button>
</div>
</form>
</div>
</div>
@ -149,7 +197,6 @@ let videoTitle = '';
let videoDescription = '';
let chatHistory = [];
let unlockCode = '';
let unlocked = false;
async function loadVideoMeta(id) {
videoTitle = '';
@ -165,34 +212,70 @@ async function loadVideoMeta(id) {
}
}
document.getElementById('loadBtn').addEventListener('click', () => {
const id = extractVideoId(document.getElementById('urlInput').value);
if (!id) { alert('動画IDが読み取れませんでした'); return; }
function hideResults() {
const box = document.getElementById('searchResults');
box.classList.remove('show');
box.innerHTML = '';
}
function loadVideo(id) {
if (player && player.loadVideoById) {
player.loadVideoById(id);
}
loadVideoMeta(id);
hideResults();
}
async function runSearch(query) {
try {
const res = await fetch('/api/search?q=' + encodeURIComponent(query));
if (!res.ok) throw new Error('search failed');
const items = await res.json();
const box = document.getElementById('searchResults');
box.innerHTML = '';
for (const item of (items || [])) {
const card = document.createElement('div');
card.className = 'resultCard';
card.innerHTML = `
<img src="${item.thumbnail}" alt="">
<div class="meta">
<div class="title">${item.title}</div>
<div class="channel">${item.channelTitle}</div>
</div>`;
card.addEventListener('click', () => {
document.getElementById('urlInput').value = item.videoId;
loadVideo(item.videoId);
});
box.appendChild(card);
}
box.classList.add('show');
} catch (e) {
console.error('search error', e);
}
}
document.getElementById('loadBtn').addEventListener('click', () => {
const raw = document.getElementById('urlInput').value.trim();
if (!raw) return;
const id = extractVideoId(raw);
if (id) {
loadVideo(id);
} else {
runSearch(raw);
}
});
document.getElementById('urlInput').addEventListener('keydown', (e) => {
if (e.key === 'Enter') document.getElementById('loadBtn').click();
});
function setModelBadge(isUnlocked) {
unlocked = isUnlocked;
const badge = document.getElementById('modelBadge');
if (isUnlocked) {
badge.textContent = '🔓 Gemini';
badge.classList.add('unlocked');
const modelSelect = document.getElementById('modelSelect');
modelSelect.addEventListener('change', () => {
if (modelSelect.value === 'gemini' && !unlockCode) {
document.getElementById('unlockRow').classList.add('show');
} else {
badge.textContent = '🔓 Gemma (タップでGemini解放)';
badge.classList.remove('unlocked');
document.getElementById('unlockRow').classList.remove('show');
}
}
setModelBadge(false);
document.getElementById('modelBadge').addEventListener('click', () => {
document.getElementById('unlockRow').classList.toggle('show');
});
document.getElementById('unlockBtn').addEventListener('click', async () => {
@ -207,14 +290,14 @@ document.getElementById('unlockBtn').addEventListener('click', async () => {
const data = await res.json();
if (data.ok) {
unlockCode = code;
document.getElementById('modelBadge').textContent = '🔓 Gemini';
document.getElementById('modelBadge').classList.add('unlocked');
document.getElementById('unlockRow').classList.remove('show');
} else {
alert('コードが違います');
modelSelect.value = 'gemma';
}
} catch (e) {
alert('解放に失敗しました');
modelSelect.value = 'gemma';
}
});
@ -235,6 +318,7 @@ document.getElementById('chatForm').addEventListener('submit', async (e) => {
input.value = '';
appendBubble('user', message);
const wantsGemini = modelSelect.value === 'gemini';
try {
const res = await fetch('/api/chat', {
method: 'POST',
@ -243,7 +327,8 @@ document.getElementById('chatForm').addEventListener('submit', async (e) => {
videoTitle, videoDescription,
history: chatHistory,
message,
unlockCode,
unlockCode: wantsGemini ? unlockCode : '',
persona: document.getElementById('personaSelect').value,
}),
});
if (!res.ok) throw new Error('chat failed: ' + res.status);

View file

@ -21,6 +21,68 @@ type ytVideosResponse struct {
} `json:"items"`
}
type searchResult struct {
VideoID string `json:"videoId"`
Title string `json:"title"`
ChannelTitle string `json:"channelTitle"`
Thumbnail string `json:"thumbnail"`
}
type ytSearchResponse struct {
Items []struct {
ID struct {
VideoID string `json:"videoId"`
} `json:"id"`
Snippet struct {
Title string `json:"title"`
ChannelTitle string `json:"channelTitle"`
Thumbnails struct {
Medium struct {
URL string `json:"url"`
} `json:"medium"`
} `json:"thumbnails"`
} `json:"snippet"`
} `json:"items"`
}
func searchVideos(query, apiKey string) ([]searchResult, error) {
q := url.Values{}
q.Set("part", "snippet")
q.Set("type", "video")
q.Set("maxResults", "12")
q.Set("q", query)
q.Set("key", apiKey)
resp, err := http.Get("https://www.googleapis.com/youtube/v3/search?" + q.Encode())
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("youtube search status %d", resp.StatusCode)
}
var parsed ytSearchResponse
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
return nil, err
}
results := make([]searchResult, 0, len(parsed.Items))
for _, item := range parsed.Items {
if item.ID.VideoID == "" {
continue
}
results = append(results, searchResult{
VideoID: item.ID.VideoID,
Title: item.Snippet.Title,
ChannelTitle: item.Snippet.ChannelTitle,
Thumbnail: item.Snippet.Thumbnails.Medium.URL,
})
}
return results, nil
}
func fetchVideoMeta(videoID, apiKey string) (*videoMeta, error) {
q := url.Values{}
q.Set("part", "snippet")