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
+
+
+
-
-
-
+
+
+
+
+
+
@@ -106,7 +141,18 @@
@@ -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 = `
+

+
`;
+ 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")