diff --git a/gemini.go b/gemini.go index 8f2e556..4e3ac8c 100644 --- a/gemini.go +++ b/gemini.go @@ -17,6 +17,9 @@ const ( modelGemini = "gemini-flash-latest" searchFuncName = "search_youtube" + playFuncName = "play_video" + + maxToolIterations = 4 ) func selectModel(unlockCode, unlockSecret string) string { @@ -41,26 +44,44 @@ func buildSystemInstruction(videoTitle, videoDescription, persona string) *genai prompt := fmt.Sprintf( "あなたはユーザーと一緒に動画を見ている友達です。タメ口で短く、実況や相槌のように反応してください。\n"+ "性格: %s\n"+ - "今見ている動画:\nタイトル: %s\n概要: %s\n"+ - "ユーザーが「次の動画」「こういうの見たい」「〇〇のやつ見せて」のように次に見る動画を探してほしそうな時は"+ - "%s関数でYouTubeを検索し、見つかったものから軽くおすすめして。", - tone, videoTitle, videoDescription, searchFuncName, + "今見ている動画:\nタイトル: %s\n概要: %s\n\n"+ + "動画検索・再生ツールの使い分けルール:\n"+ + "- ユーザーが今の動画の内容について話したり感想を言っているだけの時は、%sも%sも呼ばない。ただ会話する。\n"+ + "- ユーザーが「次の動画」「こういうの見たい」「〇〇のやつ見せて」のように次に見る動画を探してほしそうな時だけ%sを呼ぶ。\n"+ + "- %sの結果に対してユーザーがまだ何も選んでいないなら、%sは呼ばずに候補を紹介して選んでもらう。\n"+ + "- ユーザーが「任せる」「なんでもいいから」「それにして」「1個目で」のように選択をAIに委ねている、または検索結果から明確にどれかを選んだ時だけ%sを呼んで良い。\n"+ + "- 判断に迷う時は再生せず、必ず候補を提示するだけにする(誤って動画を切り替えない方を優先)。", + tone, videoTitle, videoDescription, + searchFuncName, playFuncName, searchFuncName, searchFuncName, playFuncName, playFuncName, ) return genai.NewContentFromText(prompt, genai.RoleUser) } -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検索キーワード"}, +func chatTools() []*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"}, + }, + }, + { + Name: playFuncName, + Description: "検索結果や会話から選ばれた動画に実際に切り替えて再生する。ユーザーが選択をAIに委ねた時、または明確にどれかを選んだ時のみ使う。", + Parameters: &genai.Schema{ + Type: genai.TypeObject, + Properties: map[string]*genai.Schema{ + "videoId": {Type: genai.TypeString, Description: "再生するYouTube動画ID"}, + }, + Required: []string{"videoId"}, }, - Required: []string{"query"}, }, }, }, @@ -68,40 +89,13 @@ func searchTool() *genai.Tool { } type chatOutcome struct { - Reply string - Videos []searchResult - Model string + Reply string + Videos []searchResult + AutoPlayVideoID string + 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)) - - config := &genai.GenerateContentConfig{ - SystemInstruction: buildSystemInstruction(videoTitle, videoDescription, persona), - Tools: []*genai.Tool{searchTool()}, - } - - 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 - } - +func searchResultsToFuncResponse(results []searchResult) map[string]any { items := make([]map[string]any, len(results)) for i, r := range results { items[i] = map[string]any{ @@ -110,16 +104,64 @@ func chatReply(ctx context.Context, client *genai.Client, model, ytAPIKey, video "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 + return map[string]any{"results": items} +} + +func chatReply(ctx context.Context, client *genai.Client, model, ytAPIKey, videoTitle, videoDescription, persona string, history []chatTurn, message string, onStatus func(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)) + + config := &genai.GenerateContentConfig{ + SystemInstruction: buildSystemInstruction(videoTitle, videoDescription, persona), + Tools: chatTools(), + } + + outcome := &chatOutcome{Model: model} + + for i := 0; i < maxToolIterations; i++ { + resp, err := client.Models.GenerateContent(ctx, model, contents, config) + if err != nil { + return nil, err + } + + calls := resp.FunctionCalls() + if len(calls) == 0 { + outcome.Reply = resp.Text() + return outcome, nil + } + + if len(resp.Candidates) > 0 { + contents = append(contents, resp.Candidates[0].Content) + } + + for _, call := range calls { + switch call.Name { + case searchFuncName: + if onStatus != nil { + onStatus("🔍 探してるよ…") + } + query, _ := call.Args["query"].(string) + results, err := searchVideos(query, ytAPIKey) + if err != nil { + results = nil + } + outcome.Videos = results + contents = append(contents, genai.NewContentFromFunctionResponse(call.Name, searchResultsToFuncResponse(results), genai.RoleUser)) + case playFuncName: + if onStatus != nil { + onStatus("▶ 切り替えてるよ…") + } + videoID, _ := call.Args["videoId"].(string) + outcome.AutoPlayVideoID = videoID + contents = append(contents, genai.NewContentFromFunctionResponse(call.Name, map[string]any{"ok": true, "videoId": videoID}, genai.RoleUser)) + default: + contents = append(contents, genai.NewContentFromFunctionResponse(call.Name, map[string]any{"ok": false}, genai.RoleUser)) + } + } + } + + return nil, fmt.Errorf("tool call iteration limit reached") } diff --git a/main.go b/main.go index 19c364e..04aa774 100644 --- a/main.go +++ b/main.go @@ -85,10 +85,13 @@ type chatRequest struct { Persona string `json:"persona"` } -type chatResponse struct { - Reply string `json:"reply"` - Model string `json:"model"` - Videos []searchResult `json:"videos,omitempty"` +type chatStreamEvent struct { + Type string `json:"type"` // "status" | "final" | "error" + Text string `json:"text,omitempty"` + Reply string `json:"reply,omitempty"` + Model string `json:"model,omitempty"` + Videos []searchResult `json:"videos,omitempty"` + AutoPlayVideoID string `json:"autoPlayVideoId,omitempty"` } func handleChat(w http.ResponseWriter, r *http.Request) { @@ -102,15 +105,33 @@ func handleChat(w http.ResponseWriter, r *http.Request) { return } + w.Header().Set("Content-Type", "application/x-ndjson") + flusher, _ := w.(http.Flusher) + encoder := json.NewEncoder(w) + + writeEvent := func(ev chatStreamEvent) { + encoder.Encode(ev) + if flusher != nil { + flusher.Flush() + } + } + model := selectModel(req.UnlockCode, os.Getenv("UNLOCK_CODE")) - outcome, err := chatReply(r.Context(), geminiClient, model, os.Getenv("YOUTUBE_API_KEY"), 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, func(status string) { + writeEvent(chatStreamEvent{Type: "status", Text: status}) + }) if err != nil { - http.Error(w, err.Error(), http.StatusBadGateway) + writeEvent(chatStreamEvent{Type: "error", Text: err.Error()}) return } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(chatResponse{Reply: outcome.Reply, Model: outcome.Model, Videos: outcome.Videos}) + writeEvent(chatStreamEvent{ + Type: "final", + Reply: outcome.Reply, + Model: outcome.Model, + Videos: outcome.Videos, + AutoPlayVideoID: outcome.AutoPlayVideoID, + }) } type unlockRequest struct { diff --git a/web/index.html b/web/index.html index 9d62d79..51aa617 100644 --- a/web/index.html +++ b/web/index.html @@ -79,31 +79,45 @@ } .iconBtn:hover { filter: brightness(1.08); } - .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; - } - .searchPanel.show { display: block; } - .ytRow { display: flex; gap: 10px; padding: 8px; border-radius: 10px; cursor: pointer; align-items: flex-start; } + .ytRow { display: flex; gap: 10px; padding: 8px; border-radius: 10px; cursor: pointer; align-items: flex-start; white-space: normal; } .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; } + .ytRow .title { font-size: 13px; line-height: 1.4; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; white-space: normal; } + .ytRow .channel { font-size: 11.5px; color: var(--on-surface-dim); margin-top: 4px; white-space: normal; } - .inlineSearch { margin-top: 6px; background: var(--surface-2); border-radius: var(--radius-md); padding: 4px; } + .inlineSearch { margin-top: 6px; background: var(--surface-2); border-radius: var(--radius-md); padding: 4px; white-space: normal; } .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; } + .playerStage { flex: 1; display: flex; align-items: center; justify-content: center; min-height: 0; position: relative; } .playerWrap { position: relative; width: 100%; max-width: 1100px; aspect-ratio: 16/9; background: #000; border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--elevation-2); + transition: width .25s ease, height .25s ease, right .25s ease, bottom .25s ease, box-shadow .25s ease; + } + .playerWrap.pip { + position: fixed; right: 20px; bottom: 20px; width: 260px; height: 146px; max-width: none; + z-index: 40; box-shadow: var(--elevation-3); border: 1px solid var(--outline); cursor: pointer; } #player { position: absolute; inset: 0; width: 100%; height: 100%; } + .searchStage { + display: none; flex-direction: column; width: 100%; height: 100%; + background: var(--surface-2); border-radius: var(--radius-md); overflow: hidden; box-shadow: var(--elevation-1); + } + .searchStage.show { display: flex; } + .searchStage .searchHeader { + display: flex; align-items: center; gap: 8px; padding: 10px 14px; border-bottom: 1px solid var(--outline); flex-shrink: 0; + } + .searchStage .searchHeader button { + background: none; border: none; color: var(--on-surface); cursor: pointer; + display: flex; align-items: center; justify-content: center; border-radius: 50%; width: 32px; height: 32px; + } + .searchStage .searchHeader button:hover { background: var(--surface-3); } + .searchStage .searchHeader .label { font-size: 13px; color: var(--on-surface-dim); } + .searchStage .searchList { flex: 1; overflow-y: auto; padding: 8px; } + .chatPane { display: flex; flex-direction: column; min-width: 0; background: var(--surface); border-left: 1px solid var(--outline); @@ -128,12 +142,23 @@ .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; max-width: 100%; } + .bubble.status { display: flex; align-items: center; gap: 8px; } + .bubble .statusText { font-size: 13px; color: var(--on-surface-dim); } + .bubble .dots { display: inline-flex; gap: 3px; } + .bubble .dots .dot { + width: 6px; height: 6px; border-radius: 50%; background: var(--on-surface-dim); + animation: dotBlink 1.2s infinite ease-in-out; + } + .bubble .dots .dot:nth-child(2) { animation-delay: .2s; } + .bubble .dots .dot:nth-child(3) { animation-delay: .4s; } + @keyframes dotBlink { 0%, 80%, 100% { opacity: .2; } 40% { opacity: 1; } } #chatForm { padding: 12px 16px 16px; border-top: 1px solid var(--outline); } @media (max-width: 900px) { .app { grid-template-columns: 1fr; grid-template-rows: 1fr 340px; } .chatPane { border-left: none; border-top: 1px solid var(--outline); } + .playerWrap.pip { width: 180px; height: 101px; right: 12px; bottom: 12px; } } @@ -147,9 +172,15 @@ -