Material Design刷新、YouTube検索UI、モデル/キャラのプルダウン選択を追加
This commit is contained in:
parent
e8628e59a0
commit
e9276fb8ea
4 changed files with 263 additions and 87 deletions
20
gemini.go
20
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
|
||||
|
|
|
|||
19
main.go
19
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
|
||||
|
|
|
|||
247
web/index.html
247
web/index.html
|
|
@ -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);
|
||||
|
|
|
|||
62
youtube.go
62
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")
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue