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
|
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(
|
prompt := fmt.Sprintf(
|
||||||
"あなたはユーザーと一緒に動画を見ている友達です。タメ口で短く、実況や相槌のように反応してください。\n"+
|
"あなたはユーザーと一緒に動画を見ている友達です。タメ口で短く、実況や相槌のように反応してください。\n"+
|
||||||
|
"性格: %s\n"+
|
||||||
"今見ている動画:\nタイトル: %s\n概要: %s",
|
"今見ている動画:\nタイトル: %s\n概要: %s",
|
||||||
videoTitle, videoDescription,
|
tone, videoTitle, videoDescription,
|
||||||
)
|
)
|
||||||
return genai.NewContentFromText(prompt, genai.RoleUser)
|
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)
|
contents := make([]*genai.Content, 0, len(history)+1)
|
||||||
for _, h := range history {
|
for _, h := range history {
|
||||||
contents = append(contents, genai.NewContentFromText(h.Text, genai.Role(h.Role)))
|
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))
|
contents = append(contents, genai.NewContentFromText(message, genai.RoleUser))
|
||||||
|
|
||||||
resp, err := client.Models.GenerateContent(ctx, model, contents, &genai.GenerateContentConfig{
|
resp, err := client.Models.GenerateContent(ctx, model, contents, &genai.GenerateContentConfig{
|
||||||
SystemInstruction: buildSystemInstruction(videoTitle, videoDescription),
|
SystemInstruction: buildSystemInstruction(videoTitle, videoDescription, persona),
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
|
|
|
||||||
19
main.go
19
main.go
|
|
@ -35,6 +35,7 @@ func main() {
|
||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
http.HandleFunc("/api/video-meta", handleVideoMeta)
|
http.HandleFunc("/api/video-meta", handleVideoMeta)
|
||||||
|
http.HandleFunc("/api/search", handleSearch)
|
||||||
http.HandleFunc("/api/chat", handleChat)
|
http.HandleFunc("/api/chat", handleChat)
|
||||||
http.HandleFunc("/api/unlock", handleUnlock)
|
http.HandleFunc("/api/unlock", handleUnlock)
|
||||||
http.Handle("/", http.FileServer(http.Dir("web")))
|
http.Handle("/", http.FileServer(http.Dir("web")))
|
||||||
|
|
@ -60,12 +61,28 @@ func handleVideoMeta(w http.ResponseWriter, r *http.Request) {
|
||||||
json.NewEncoder(w).Encode(meta)
|
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 {
|
type chatRequest struct {
|
||||||
VideoTitle string `json:"videoTitle"`
|
VideoTitle string `json:"videoTitle"`
|
||||||
VideoDescription string `json:"videoDescription"`
|
VideoDescription string `json:"videoDescription"`
|
||||||
History []chatTurn `json:"history"`
|
History []chatTurn `json:"history"`
|
||||||
Message string `json:"message"`
|
Message string `json:"message"`
|
||||||
UnlockCode string `json:"unlockCode"`
|
UnlockCode string `json:"unlockCode"`
|
||||||
|
Persona string `json:"persona"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type chatResponse struct {
|
type chatResponse struct {
|
||||||
|
|
@ -85,7 +102,7 @@ func handleChat(w http.ResponseWriter, r *http.Request) {
|
||||||
}
|
}
|
||||||
|
|
||||||
model := selectModel(req.UnlockCode, os.Getenv("UNLOCK_CODE"))
|
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 {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
return
|
return
|
||||||
|
|
|
||||||
247
web/index.html
247
web/index.html
|
|
@ -4,100 +4,135 @@
|
||||||
<meta charset="utf-8">
|
<meta charset="utf-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>aibow</title>
|
<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>
|
<style>
|
||||||
:root {
|
:root {
|
||||||
--bg: #0b0c10;
|
--bg: #131314;
|
||||||
--panel: rgba(255,255,255,0.05);
|
--surface: #1e1f20;
|
||||||
--panel-border: rgba(255,255,255,0.08);
|
--surface-2: #282a2c;
|
||||||
--text: #e9ebf0;
|
--surface-3: #333537;
|
||||||
--text-dim: #9096a3;
|
--outline: #444746;
|
||||||
--accent: #6c8bff;
|
--on-surface: #e3e3e3;
|
||||||
--accent-2: #8f6cff;
|
--on-surface-dim: #9aa0a6;
|
||||||
--bubble-user: linear-gradient(135deg, #6c8bff, #8f6cff);
|
--primary: #a8c7fa;
|
||||||
--bubble-model: rgba(255,255,255,0.07);
|
--on-primary: #062e6f;
|
||||||
--radius: 16px;
|
--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; }
|
* { box-sizing: border-box; }
|
||||||
html, body { height: 100%; }
|
html, body { height: 100%; }
|
||||||
body {
|
body {
|
||||||
margin: 0;
|
margin: 0;
|
||||||
font-family: "Hiragino Kaku Gothic ProN", "Yu Gothic", system-ui, sans-serif;
|
font-family: "Roboto", "Hiragino Kaku Gothic ProN", "Yu Gothic", sans-serif;
|
||||||
background: radial-gradient(1200px 800px at 20% -10%, #17193a 0%, var(--bg) 60%);
|
background: var(--bg);
|
||||||
color: var(--text);
|
color: var(--on-surface);
|
||||||
}
|
}
|
||||||
.app { display: grid; grid-template-columns: 1fr 360px; height: 100vh; }
|
.app { display: grid; grid-template-columns: 1fr 380px; height: 100vh; }
|
||||||
.videoPane { display: flex; flex-direction: column; min-width: 0; }
|
.videoPane { display: flex; flex-direction: column; min-width: 0; padding: 12px; gap: 10px; }
|
||||||
.urlBar { display: flex; gap: 8px; padding: 14px 16px; }
|
|
||||||
.urlBar input {
|
.topBar {
|
||||||
flex: 1; padding: 10px 14px; border-radius: 999px; border: 1px solid var(--panel-border);
|
display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
|
||||||
background: var(--panel); color: var(--text); outline: none; font-size: 14px;
|
|
||||||
}
|
}
|
||||||
.urlBar input:focus { border-color: var(--accent); }
|
.brand { font-weight: 700; font-size: 16px; display: flex; align-items: center; gap: 6px; margin-right: auto; }
|
||||||
.urlBar button {
|
|
||||||
padding: 10px 20px; border-radius: 999px; border: none; cursor: pointer;
|
.mdSelect {
|
||||||
background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #fff; font-weight: 600;
|
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 {
|
.playerWrap {
|
||||||
position: relative; width: 100%; max-width: 1100px; aspect-ratio: 16/9;
|
position: relative; width: 100%; max-width: 1100px; aspect-ratio: 16/9;
|
||||||
background: #000; border-radius: var(--radius); overflow: hidden;
|
background: #000; border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--elevation-2);
|
||||||
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
|
||||||
}
|
}
|
||||||
#player { position: absolute; inset: 0; width: 100%; height: 100%; }
|
#player { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||||
|
|
||||||
.chatPane {
|
.chatPane {
|
||||||
display: flex; flex-direction: column; min-width: 0;
|
display: flex; flex-direction: column; min-width: 0;
|
||||||
background: var(--panel); border-left: 1px solid var(--panel-border);
|
background: var(--surface); border-left: 1px solid var(--outline);
|
||||||
backdrop-filter: blur(12px);
|
|
||||||
}
|
}
|
||||||
.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; }
|
.chatHeader .name { font-weight: 700; font-size: 15px; display: flex; align-items: center; gap: 6px; }
|
||||||
.modelBadge {
|
.selectorRow { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||||
margin-top: 8px; display: inline-flex; align-items: center; gap: 6px;
|
|
||||||
padding: 4px 10px; border-radius: 999px; font-size: 12px; cursor: pointer;
|
.unlockRow { display: none; gap: 6px; }
|
||||||
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; }
|
|
||||||
.unlockRow.show { display: flex; }
|
.unlockRow.show { display: flex; }
|
||||||
.unlockRow input {
|
.unlockRow input {
|
||||||
flex: 1; padding: 6px 10px; border-radius: 8px; border: 1px solid var(--panel-border);
|
flex: 1; padding: 8px 12px; border-radius: 999px; border: 1px solid var(--outline);
|
||||||
background: rgba(0,0,0,0.3); color: var(--text); font-size: 12px; outline: none;
|
background: var(--surface-2); color: var(--on-surface); font-size: 12px; outline: none;
|
||||||
}
|
}
|
||||||
.unlockRow button {
|
.unlockRow button {
|
||||||
padding: 6px 10px; border-radius: 8px; border: none; background: var(--accent); color: #fff;
|
padding: 8px 14px; border-radius: 999px; border: none; background: var(--primary);
|
||||||
font-size: 12px; cursor: pointer;
|
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; }
|
#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 { 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(--bubble-user); color: #fff; border-bottom-right-radius: 4px; }
|
.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(--bubble-model); border-bottom-left-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 { padding: 12px 16px 16px; border-top: 1px solid var(--outline); }
|
||||||
#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;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 860px) {
|
@media (max-width: 900px) {
|
||||||
.app { grid-template-columns: 1fr; grid-template-rows: 1fr 320px; }
|
.app { grid-template-columns: 1fr; grid-template-rows: 1fr 340px; }
|
||||||
.chatPane { border-left: none; border-top: 1px solid var(--panel-border); }
|
.chatPane { border-left: none; border-top: 1px solid var(--outline); }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div class="app">
|
<div class="app">
|
||||||
<div class="videoPane">
|
<div class="videoPane">
|
||||||
<div class="urlBar">
|
<div class="topBar">
|
||||||
<input id="urlInput" type="text" placeholder="YouTubeのURLまたは動画IDを入力">
|
<div class="brand">🐾 aibow</div>
|
||||||
<button id="loadBtn">読み込む</button>
|
|
||||||
</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="playerStage">
|
||||||
<div class="playerWrap"><div id="player"></div></div>
|
<div class="playerWrap"><div id="player"></div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -106,7 +141,18 @@
|
||||||
<div class="chatPane">
|
<div class="chatPane">
|
||||||
<div class="chatHeader">
|
<div class="chatHeader">
|
||||||
<div class="name">🐾 aibow</div>
|
<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">
|
<div id="unlockRow" class="unlockRow">
|
||||||
<input id="unlockInput" type="text" placeholder="解放コード">
|
<input id="unlockInput" type="text" placeholder="解放コード">
|
||||||
<button id="unlockBtn">解放</button>
|
<button id="unlockBtn">解放</button>
|
||||||
|
|
@ -114,8 +160,10 @@
|
||||||
</div>
|
</div>
|
||||||
<div id="chatLog"></div>
|
<div id="chatLog"></div>
|
||||||
<form id="chatForm">
|
<form id="chatForm">
|
||||||
|
<div class="pillInput">
|
||||||
<input id="chatInput" type="text" placeholder="ひとこと話しかける(暫定:打ち込み式)" autocomplete="off">
|
<input id="chatInput" type="text" placeholder="ひとこと話しかける(暫定:打ち込み式)" autocomplete="off">
|
||||||
<button type="submit">送信</button>
|
<button type="submit" class="iconBtn" title="送信">➤</button>
|
||||||
|
</div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -149,7 +197,6 @@ let videoTitle = '';
|
||||||
let videoDescription = '';
|
let videoDescription = '';
|
||||||
let chatHistory = [];
|
let chatHistory = [];
|
||||||
let unlockCode = '';
|
let unlockCode = '';
|
||||||
let unlocked = false;
|
|
||||||
|
|
||||||
async function loadVideoMeta(id) {
|
async function loadVideoMeta(id) {
|
||||||
videoTitle = '';
|
videoTitle = '';
|
||||||
|
|
@ -165,34 +212,70 @@ async function loadVideoMeta(id) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('loadBtn').addEventListener('click', () => {
|
function hideResults() {
|
||||||
const id = extractVideoId(document.getElementById('urlInput').value);
|
const box = document.getElementById('searchResults');
|
||||||
if (!id) { alert('動画IDが読み取れませんでした'); return; }
|
box.classList.remove('show');
|
||||||
|
box.innerHTML = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadVideo(id) {
|
||||||
if (player && player.loadVideoById) {
|
if (player && player.loadVideoById) {
|
||||||
player.loadVideoById(id);
|
player.loadVideoById(id);
|
||||||
}
|
}
|
||||||
loadVideoMeta(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) => {
|
document.getElementById('urlInput').addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter') document.getElementById('loadBtn').click();
|
if (e.key === 'Enter') document.getElementById('loadBtn').click();
|
||||||
});
|
});
|
||||||
|
|
||||||
function setModelBadge(isUnlocked) {
|
const modelSelect = document.getElementById('modelSelect');
|
||||||
unlocked = isUnlocked;
|
modelSelect.addEventListener('change', () => {
|
||||||
const badge = document.getElementById('modelBadge');
|
if (modelSelect.value === 'gemini' && !unlockCode) {
|
||||||
if (isUnlocked) {
|
document.getElementById('unlockRow').classList.add('show');
|
||||||
badge.textContent = '🔓 Gemini';
|
|
||||||
badge.classList.add('unlocked');
|
|
||||||
} else {
|
} else {
|
||||||
badge.textContent = '🔓 Gemma (タップでGemini解放)';
|
document.getElementById('unlockRow').classList.remove('show');
|
||||||
badge.classList.remove('unlocked');
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
setModelBadge(false);
|
|
||||||
|
|
||||||
document.getElementById('modelBadge').addEventListener('click', () => {
|
|
||||||
document.getElementById('unlockRow').classList.toggle('show');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
document.getElementById('unlockBtn').addEventListener('click', async () => {
|
document.getElementById('unlockBtn').addEventListener('click', async () => {
|
||||||
|
|
@ -207,14 +290,14 @@ document.getElementById('unlockBtn').addEventListener('click', async () => {
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
if (data.ok) {
|
if (data.ok) {
|
||||||
unlockCode = code;
|
unlockCode = code;
|
||||||
document.getElementById('modelBadge').textContent = '🔓 Gemini';
|
|
||||||
document.getElementById('modelBadge').classList.add('unlocked');
|
|
||||||
document.getElementById('unlockRow').classList.remove('show');
|
document.getElementById('unlockRow').classList.remove('show');
|
||||||
} else {
|
} else {
|
||||||
alert('コードが違います');
|
alert('コードが違います');
|
||||||
|
modelSelect.value = 'gemma';
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('解放に失敗しました');
|
alert('解放に失敗しました');
|
||||||
|
modelSelect.value = 'gemma';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -235,6 +318,7 @@ document.getElementById('chatForm').addEventListener('submit', async (e) => {
|
||||||
input.value = '';
|
input.value = '';
|
||||||
appendBubble('user', message);
|
appendBubble('user', message);
|
||||||
|
|
||||||
|
const wantsGemini = modelSelect.value === 'gemini';
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/chat', {
|
const res = await fetch('/api/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -243,7 +327,8 @@ document.getElementById('chatForm').addEventListener('submit', async (e) => {
|
||||||
videoTitle, videoDescription,
|
videoTitle, videoDescription,
|
||||||
history: chatHistory,
|
history: chatHistory,
|
||||||
message,
|
message,
|
||||||
unlockCode,
|
unlockCode: wantsGemini ? unlockCode : '',
|
||||||
|
persona: document.getElementById('personaSelect').value,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('chat failed: ' + res.status);
|
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"`
|
} `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) {
|
func fetchVideoMeta(videoID, apiKey string) (*videoMeta, error) {
|
||||||
q := url.Values{}
|
q := url.Values{}
|
||||||
q.Set("part", "snippet")
|
q.Set("part", "snippet")
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue