From e9276fb8ea87155e79af63cffacd28b88633769b Mon Sep 17 00:00:00 2001 From: tmk3ki Date: Wed, 22 Jul 2026 02:34:33 +0900 Subject: [PATCH] =?UTF-8?q?Material=20Design=E5=88=B7=E6=96=B0=E3=80=81You?= =?UTF-8?q?Tube=E6=A4=9C=E7=B4=A2UI=E3=80=81=E3=83=A2=E3=83=87=E3=83=AB/?= =?UTF-8?q?=E3=82=AD=E3=83=A3=E3=83=A9=E3=81=AE=E3=83=97=E3=83=AB=E3=83=80?= =?UTF-8?q?=E3=82=A6=E3=83=B3=E9=81=B8=E6=8A=9E=E3=82=92=E8=BF=BD=E5=8A=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- gemini.go | 20 +++- main.go | 19 +++- web/index.html | 249 +++++++++++++++++++++++++++++++++---------------- youtube.go | 62 ++++++++++++ 4 files changed, 263 insertions(+), 87 deletions(-) diff --git a/gemini.go b/gemini.go index 0f47057..1d2543a 100644 --- a/gemini.go +++ b/gemini.go @@ -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 diff --git a/main.go b/main.go index 810997f..5362d51 100644 --- a/main.go +++ b/main.go @@ -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 diff --git a/web/index.html b/web/index.html index 94df4a7..9ada939 100644 --- a/web/index.html +++ b/web/index.html @@ -4,100 +4,135 @@ aibow + + +
-
- - +
+
🐾 aibow
+
+ + +
+
@@ -106,7 +141,18 @@
🐾 aibow
-
🔓 Gemma
+
+ + +
@@ -114,8 +160,10 @@
- - +
+ + +
@@ -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 = ` + +
+
${item.title}
+
${item.channelTitle}
+
`; + 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); diff --git a/youtube.go b/youtube.go index fb2f4bf..f87e9d1 100644 --- a/youtube.go +++ b/youtube.go @@ -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")