Material Symbolsアイコン化、AIbow表記統一、YouTube風検索パネル、function callingによる会話内検索を実装
This commit is contained in:
parent
e9276fb8ea
commit
e5d55f59ef
3 changed files with 166 additions and 62 deletions
79
gemini.go
79
gemini.go
|
|
@ -15,6 +15,8 @@ type chatTurn struct {
|
|||
const (
|
||||
modelGemma = "gemma-4-26b-a4b-it"
|
||||
modelGemini = "gemini-flash-latest"
|
||||
|
||||
searchFuncName = "search_youtube"
|
||||
)
|
||||
|
||||
func selectModel(unlockCode, unlockSecret string) string {
|
||||
|
|
@ -39,24 +41,85 @@ func buildSystemInstruction(videoTitle, videoDescription, persona string) *genai
|
|||
prompt := fmt.Sprintf(
|
||||
"あなたはユーザーと一緒に動画を見ている友達です。タメ口で短く、実況や相槌のように反応してください。\n"+
|
||||
"性格: %s\n"+
|
||||
"今見ている動画:\nタイトル: %s\n概要: %s",
|
||||
tone, videoTitle, videoDescription,
|
||||
"今見ている動画:\nタイトル: %s\n概要: %s\n"+
|
||||
"ユーザーが「次の動画」「こういうの見たい」「〇〇のやつ見せて」のように次に見る動画を探してほしそうな時は"+
|
||||
"%s関数でYouTubeを検索し、見つかったものから軽くおすすめして。",
|
||||
tone, videoTitle, videoDescription, searchFuncName,
|
||||
)
|
||||
return genai.NewContentFromText(prompt, genai.RoleUser)
|
||||
}
|
||||
|
||||
func chatReply(ctx context.Context, client *genai.Client, model, videoTitle, videoDescription, persona string, history []chatTurn, message string) (string, error) {
|
||||
func searchTool() *genai.Tool {
|
||||
return &genai.Tool{
|
||||
FunctionDeclarations: []*genai.FunctionDeclaration{
|
||||
{
|
||||
Name: searchFuncName,
|
||||
Description: "ユーザーが次に見たい動画・関連動画・代替の動画を探すためにYouTubeを検索する。",
|
||||
Parameters: &genai.Schema{
|
||||
Type: genai.TypeObject,
|
||||
Properties: map[string]*genai.Schema{
|
||||
"query": {Type: genai.TypeString, Description: "YouTube検索キーワード"},
|
||||
},
|
||||
Required: []string{"query"},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
type chatOutcome struct {
|
||||
Reply string
|
||||
Videos []searchResult
|
||||
Model string
|
||||
}
|
||||
|
||||
func chatReply(ctx context.Context, client *genai.Client, model, ytAPIKey, videoTitle, videoDescription, persona string, history []chatTurn, message string) (*chatOutcome, error) {
|
||||
contents := make([]*genai.Content, 0, len(history)+1)
|
||||
for _, h := range history {
|
||||
contents = append(contents, genai.NewContentFromText(h.Text, genai.Role(h.Role)))
|
||||
}
|
||||
contents = append(contents, genai.NewContentFromText(message, genai.RoleUser))
|
||||
|
||||
resp, err := client.Models.GenerateContent(ctx, model, contents, &genai.GenerateContentConfig{
|
||||
config := &genai.GenerateContentConfig{
|
||||
SystemInstruction: buildSystemInstruction(videoTitle, videoDescription, persona),
|
||||
})
|
||||
if err != nil {
|
||||
return "", err
|
||||
Tools: []*genai.Tool{searchTool()},
|
||||
}
|
||||
return resp.Text(), nil
|
||||
|
||||
resp, err := client.Models.GenerateContent(ctx, model, contents, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
calls := resp.FunctionCalls()
|
||||
if len(calls) == 0 {
|
||||
return &chatOutcome{Reply: resp.Text(), Model: model}, nil
|
||||
}
|
||||
|
||||
call := calls[0]
|
||||
query, _ := call.Args["query"].(string)
|
||||
results, err := searchVideos(query, ytAPIKey)
|
||||
if err != nil {
|
||||
results = nil
|
||||
}
|
||||
|
||||
items := make([]map[string]any, len(results))
|
||||
for i, r := range results {
|
||||
items[i] = map[string]any{
|
||||
"videoId": r.VideoID,
|
||||
"title": r.Title,
|
||||
"channelTitle": r.ChannelTitle,
|
||||
}
|
||||
}
|
||||
|
||||
if len(resp.Candidates) > 0 {
|
||||
contents = append(contents, resp.Candidates[0].Content)
|
||||
}
|
||||
contents = append(contents, genai.NewContentFromFunctionResponse(call.Name, map[string]any{"results": items}, genai.RoleUser))
|
||||
|
||||
resp2, err := client.Models.GenerateContent(ctx, model, contents, config)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &chatOutcome{Reply: resp2.Text(), Videos: results, Model: model}, nil
|
||||
}
|
||||
|
|
|
|||
5
main.go
5
main.go
|
|
@ -88,6 +88,7 @@ type chatRequest struct {
|
|||
type chatResponse struct {
|
||||
Reply string `json:"reply"`
|
||||
Model string `json:"model"`
|
||||
Videos []searchResult `json:"videos,omitempty"`
|
||||
}
|
||||
|
||||
func handleChat(w http.ResponseWriter, r *http.Request) {
|
||||
|
|
@ -102,14 +103,14 @@ 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.Persona, req.History, req.Message)
|
||||
outcome, err := chatReply(r.Context(), geminiClient, model, os.Getenv("YOUTUBE_API_KEY"), req.VideoTitle, req.VideoDescription, req.Persona, req.History, req.Message)
|
||||
if err != nil {
|
||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
json.NewEncoder(w).Encode(chatResponse{Reply: reply, Model: model})
|
||||
json.NewEncoder(w).Encode(chatResponse{Reply: outcome.Reply, Model: outcome.Model, Videos: outcome.Videos})
|
||||
}
|
||||
|
||||
type unlockRequest struct {
|
||||
|
|
|
|||
134
web/index.html
134
web/index.html
|
|
@ -3,10 +3,11 @@
|
|||
<head>
|
||||
<meta charset="utf-8">
|
||||
<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">
|
||||
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200" rel="stylesheet">
|
||||
<style>
|
||||
:root {
|
||||
--bg: #131314;
|
||||
|
|
@ -21,6 +22,7 @@
|
|||
--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);
|
||||
--elevation-3: 0 4px 8px rgba(0,0,0,.4), 0 6px 20px 4px rgba(0,0,0,.4);
|
||||
--radius-lg: 24px;
|
||||
--radius-md: 16px;
|
||||
}
|
||||
|
|
@ -32,13 +34,19 @@
|
|||
background: var(--bg);
|
||||
color: var(--on-surface);
|
||||
}
|
||||
.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;
|
||||
.material-symbols-outlined {
|
||||
font-family: 'Material Symbols Outlined';
|
||||
font-weight: normal; font-style: normal;
|
||||
font-variation-settings: 'FILL' 1, 'wght' 400, 'GRAD' 0, 'opsz' 24;
|
||||
font-size: 20px; line-height: 1; vertical-align: middle; user-select: none;
|
||||
display: inline-block; white-space: nowrap; word-wrap: normal; direction: ltr;
|
||||
}
|
||||
.app { display: grid; grid-template-columns: 1fr 380px; height: 100vh; }
|
||||
.videoPane { display: flex; flex-direction: column; min-width: 0; padding: 12px; gap: 10px; position: relative; }
|
||||
|
||||
.topBar { display: flex; align-items: center; gap: 10px; flex-wrap: wrap; }
|
||||
.brand { font-weight: 700; font-size: 16px; display: flex; align-items: center; gap: 6px; margin-right: auto; }
|
||||
.brand .material-symbols-outlined { font-size: 22px; color: var(--primary); }
|
||||
|
||||
.mdSelect {
|
||||
appearance: none; -webkit-appearance: none;
|
||||
|
|
@ -49,6 +57,9 @@
|
|||
background-repeat: no-repeat; background-position: right 8px center; background-size: 18px;
|
||||
}
|
||||
.mdSelect:focus { outline: none; border-color: var(--primary); }
|
||||
.selectWrap { display: flex; align-items: center; gap: 4px; }
|
||||
.selectWrap .material-symbols-outlined { font-size: 16px; color: var(--on-surface-dim); }
|
||||
.selectWrap .material-symbols-outlined.unlocked { color: #ffd479; }
|
||||
|
||||
.pillInput {
|
||||
display: flex; align-items: center; gap: 4px;
|
||||
|
|
@ -64,24 +75,27 @@
|
|||
.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;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
}
|
||||
.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;
|
||||
.searchPanel {
|
||||
display: none; position: absolute; top: 62px; left: 12px; z-index: 20;
|
||||
width: min(420px, calc(100% - 24px)); max-height: 420px; overflow-y: auto;
|
||||
background: var(--surface-2); border: 1px solid var(--outline); border-radius: var(--radius-md);
|
||||
box-shadow: var(--elevation-3); padding: 6px;
|
||||
}
|
||||
.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; }
|
||||
.searchPanel.show { display: block; }
|
||||
.ytRow { display: flex; gap: 10px; padding: 8px; border-radius: 10px; cursor: pointer; align-items: flex-start; }
|
||||
.ytRow:hover { background: var(--surface-3); }
|
||||
.ytRow img { width: 120px; aspect-ratio: 16/9; object-fit: cover; border-radius: 8px; background: #000; flex-shrink: 0; }
|
||||
.ytRow .meta { min-width: 0; }
|
||||
.ytRow .title { font-size: 13px; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||
.ytRow .channel { font-size: 11.5px; color: var(--on-surface-dim); margin-top: 4px; }
|
||||
|
||||
.inlineSearch { margin-top: 6px; background: var(--surface-2); border-radius: var(--radius-md); padding: 4px; }
|
||||
.inlineSearch .ytRow img { width: 90px; }
|
||||
.inlineSearch .ytRow .title { font-size: 12px; }
|
||||
|
||||
.playerStage { flex: 1; display: flex; align-items: center; justify-content: center; min-height: 0; }
|
||||
.playerWrap {
|
||||
|
|
@ -96,6 +110,7 @@
|
|||
}
|
||||
.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 .material-symbols-outlined { font-size: 20px; color: var(--primary); }
|
||||
.selectorRow { display: flex; gap: 8px; flex-wrap: wrap; }
|
||||
|
||||
.unlockRow { display: none; gap: 6px; }
|
||||
|
|
@ -110,9 +125,9 @@
|
|||
}
|
||||
|
||||
#chatLog { flex: 1; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; padding: 16px; }
|
||||
.bubble { padding: 10px 14px; border-radius: var(--radius-md); 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: 92%; 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; }
|
||||
.bubble.model { align-self: flex-start; background: var(--surface-3); border-bottom-left-radius: 4px; max-width: 100%; }
|
||||
|
||||
#chatForm { padding: 12px 16px 16px; border-top: 1px solid var(--outline); }
|
||||
|
||||
|
|
@ -126,13 +141,13 @@
|
|||
<div class="app">
|
||||
<div class="videoPane">
|
||||
<div class="topBar">
|
||||
<div class="brand">🐾 aibow</div>
|
||||
<div class="brand"><span class="material-symbols-outlined">smart_toy</span>AIbow</div>
|
||||
</div>
|
||||
<div class="pillInput">
|
||||
<input id="urlInput" type="text" placeholder="YouTubeのURL・動画ID・検索キーワード">
|
||||
<button id="loadBtn" class="iconBtn" title="読み込み/検索">🔎</button>
|
||||
<button id="loadBtn" class="iconBtn" title="読み込み/検索"><span class="material-symbols-outlined">search</span></button>
|
||||
</div>
|
||||
<div id="searchResults" class="searchResults"></div>
|
||||
<div id="searchPanel" class="searchPanel"></div>
|
||||
<div class="playerStage">
|
||||
<div class="playerWrap"><div id="player"></div></div>
|
||||
</div>
|
||||
|
|
@ -140,7 +155,7 @@
|
|||
|
||||
<div class="chatPane">
|
||||
<div class="chatHeader">
|
||||
<div class="name">🐾 aibow</div>
|
||||
<div class="name"><span class="material-symbols-outlined">smart_toy</span>AIbow</div>
|
||||
<div class="selectorRow">
|
||||
<select id="personaSelect" class="mdSelect">
|
||||
<option value="neutral">性格: ふつう</option>
|
||||
|
|
@ -148,10 +163,13 @@
|
|||
<option value="downer">性格: ダウナー</option>
|
||||
<option value="goofy">性格: ひょうきん</option>
|
||||
</select>
|
||||
<div class="selectWrap">
|
||||
<select id="modelSelect" class="mdSelect">
|
||||
<option value="gemma">モデル: Gemma (無料)</option>
|
||||
<option value="gemini">モデル: Gemini 🔒</option>
|
||||
<option value="gemini">モデル: Gemini (要コード)</option>
|
||||
</select>
|
||||
<span id="lockIcon" class="material-symbols-outlined">lock_open</span>
|
||||
</div>
|
||||
</div>
|
||||
<div id="unlockRow" class="unlockRow">
|
||||
<input id="unlockInput" type="text" placeholder="解放コード">
|
||||
|
|
@ -162,7 +180,7 @@
|
|||
<form id="chatForm">
|
||||
<div class="pillInput">
|
||||
<input id="chatInput" type="text" placeholder="ひとこと話しかける(暫定:打ち込み式)" autocomplete="off">
|
||||
<button type="submit" class="iconBtn" title="送信">➤</button>
|
||||
<button type="submit" class="iconBtn" title="送信"><span class="material-symbols-outlined">send</span></button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
|
@ -212,8 +230,8 @@ async function loadVideoMeta(id) {
|
|||
}
|
||||
}
|
||||
|
||||
function hideResults() {
|
||||
const box = document.getElementById('searchResults');
|
||||
function hideSearchPanel() {
|
||||
const box = document.getElementById('searchPanel');
|
||||
box.classList.remove('show');
|
||||
box.innerHTML = '';
|
||||
}
|
||||
|
|
@ -223,7 +241,21 @@ function loadVideo(id) {
|
|||
player.loadVideoById(id);
|
||||
}
|
||||
loadVideoMeta(id);
|
||||
hideResults();
|
||||
hideSearchPanel();
|
||||
document.getElementById('urlInput').value = id;
|
||||
}
|
||||
|
||||
function buildYtRow(item) {
|
||||
const row = document.createElement('div');
|
||||
row.className = 'ytRow';
|
||||
row.innerHTML = `
|
||||
<img src="${item.thumbnail}" alt="">
|
||||
<div class="meta">
|
||||
<div class="title">${item.title}</div>
|
||||
<div class="channel">${item.channelTitle}</div>
|
||||
</div>`;
|
||||
row.addEventListener('click', () => loadVideo(item.videoId));
|
||||
return row;
|
||||
}
|
||||
|
||||
async function runSearch(query) {
|
||||
|
|
@ -231,22 +263,10 @@ async function runSearch(query) {
|
|||
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');
|
||||
const box = document.getElementById('searchPanel');
|
||||
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.appendChild(buildYtRow(item));
|
||||
}
|
||||
box.classList.add('show');
|
||||
} catch (e) {
|
||||
|
|
@ -269,13 +289,23 @@ document.getElementById('urlInput').addEventListener('keydown', (e) => {
|
|||
if (e.key === 'Enter') document.getElementById('loadBtn').click();
|
||||
});
|
||||
|
||||
document.addEventListener('click', (e) => {
|
||||
const panel = document.getElementById('searchPanel');
|
||||
if (!panel.contains(e.target) && e.target.id !== 'urlInput' && e.target.id !== 'loadBtn') {
|
||||
hideSearchPanel();
|
||||
}
|
||||
});
|
||||
|
||||
const modelSelect = document.getElementById('modelSelect');
|
||||
const lockIcon = document.getElementById('lockIcon');
|
||||
modelSelect.addEventListener('change', () => {
|
||||
if (modelSelect.value === 'gemini' && !unlockCode) {
|
||||
document.getElementById('unlockRow').classList.add('show');
|
||||
} else {
|
||||
document.getElementById('unlockRow').classList.remove('show');
|
||||
}
|
||||
lockIcon.textContent = (modelSelect.value === 'gemini' && unlockCode) ? 'lock_open' : (modelSelect.value === 'gemini' ? 'lock' : 'lock_open');
|
||||
lockIcon.classList.toggle('unlocked', modelSelect.value === 'gemini' && !!unlockCode);
|
||||
});
|
||||
|
||||
document.getElementById('unlockBtn').addEventListener('click', async () => {
|
||||
|
|
@ -291,6 +321,8 @@ document.getElementById('unlockBtn').addEventListener('click', async () => {
|
|||
if (data.ok) {
|
||||
unlockCode = code;
|
||||
document.getElementById('unlockRow').classList.remove('show');
|
||||
lockIcon.textContent = 'lock_open';
|
||||
lockIcon.classList.add('unlocked');
|
||||
} else {
|
||||
alert('コードが違います');
|
||||
modelSelect.value = 'gemma';
|
||||
|
|
@ -301,10 +333,18 @@ document.getElementById('unlockBtn').addEventListener('click', async () => {
|
|||
}
|
||||
});
|
||||
|
||||
function appendBubble(role, text) {
|
||||
function appendBubble(role, text, videos) {
|
||||
const div = document.createElement('div');
|
||||
div.className = 'bubble ' + role;
|
||||
div.textContent = text;
|
||||
if (videos && videos.length) {
|
||||
const box = document.createElement('div');
|
||||
box.className = 'inlineSearch';
|
||||
for (const item of videos) {
|
||||
box.appendChild(buildYtRow(item));
|
||||
}
|
||||
div.appendChild(box);
|
||||
}
|
||||
const log = document.getElementById('chatLog');
|
||||
log.appendChild(div);
|
||||
log.scrollTop = log.scrollHeight;
|
||||
|
|
@ -333,7 +373,7 @@ document.getElementById('chatForm').addEventListener('submit', async (e) => {
|
|||
});
|
||||
if (!res.ok) throw new Error('chat failed: ' + res.status);
|
||||
const data = await res.json();
|
||||
appendBubble('model', data.reply);
|
||||
appendBubble('model', data.reply, data.videos);
|
||||
chatHistory.push({ role: 'user', text: message });
|
||||
chatHistory.push({ role: 'model', text: data.reply });
|
||||
} catch (err) {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue