package main import ( "context" "encoding/json" "fmt" "google.golang.org/genai" ) type chatTurn struct { Role string `json:"role"` // "user" or "model" Text string `json:"text"` } const ( modelGemma = "gemma-4-26b-a4b-it" modelGemini = "gemini-flash-latest" searchFuncName = "search_youtube" playFuncName = "play_video" maxToolIterations = 4 ) func selectModel(unlockCode, unlockSecret string) string { if unlockSecret != "" && unlockCode == unlockSecret { return modelGemini } return modelGemma } var personaTones = map[string]string{ "energetic": "テンション高めで元気いっぱい、リアクションが大きい性格。", "downer": "気だるげでローテンション、淡々としているが憎めない性格。", "goofy": "ひょうきんでボケ気味、ちょっとふざけたことを言う性格。", "neutral": "落ち着いていて素直な性格。", } func personaTone(persona string) string { tone, ok := personaTones[persona] if !ok { return personaTones["neutral"] } return tone } func buildSystemInstruction(videoTitle, videoDescription, persona string) *genai.Content { tone := personaTone(persona) prompt := fmt.Sprintf( "あなたはユーザーと一緒に動画を見ている友達です。タメ口で短く、実況や相槌のように反応してください。\n"+ "性格: %s\n"+ "今見ている動画:\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 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"}, }, }, }, }, } } type chatOutcome struct { Reply string Videos []searchResult AutoPlayVideoID string Model string } func searchResultsToFuncResponse(results []searchResult) map[string]any { 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, } } 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") } func watchReaction(ctx context.Context, client *genai.Client, model string, clipBytes []byte, mimeType, videoTitle, videoDescription, persona string, recentReactions []chatTurn) (string, error) { prompt := fmt.Sprintf( "あなたはユーザーと一緒に動画を見ている友達です。今渡す数秒ぶんの映像・音声クリップを見て、"+ "よほど印象的・面白い・驚くような場面の時だけ短く反応してください。\n"+ "性格: %s\n動画タイトル: %s\n概要: %s\n\n"+ "厳守ルール:\n"+ "- 基本は黙って見ているのが正解。ほとんどの場合は何も言わない。\n"+ "- 「楽しみだね」「いいね」「気になる」のような当たり障りのない薄い相槌・感想は絶対に言わない。\n"+ "- 単に新しい話題になった程度では言わない。本当に面白い/驚いた/笑った時だけ、具体的に何が起きたかに基づく一言(1文、短く)。\n"+ "- 少しでも迷ったら黙る(空文字を返す)方を選ぶ。10回に1回もコメントしなくていい。\n"+ "- 直近で自分が言ったこと(下記)と同じ/似た内容・話題を繰り返さない。", personaTone(persona), videoTitle, videoDescription, ) if len(recentReactions) > 0 { prompt += "\n\n直近の自分の発言:\n" for _, h := range recentReactions { if h.Role == genai.RoleModel { prompt += "- " + h.Text + "\n" } } } contents := []*genai.Content{ genai.NewContentFromParts([]*genai.Part{ genai.NewPartFromText(prompt), genai.NewPartFromBytes(clipBytes, mimeType), }, genai.RoleUser), } resp, err := client.Models.GenerateContent(ctx, model, contents, nil) if err != nil { return "", err } return resp.Text(), nil } type voiceOutcome struct { Transcript string `json:"transcript"` Reply string `json:"reply"` } func voiceChat(ctx context.Context, client *genai.Client, model string, audioBytes []byte, mimeType, videoTitle, videoDescription, persona string, history []chatTurn) (*voiceOutcome, error) { prompt := fmt.Sprintf( "あなたはユーザーと一緒に動画を見ている友達です。ユーザーが今、音声で話しかけてきました。音声を聞いて、\n"+ "1. transcript: 話した内容の文字起こし\n"+ "2. reply: タメ口で短い、友達としての返答\n"+ "の2つをJSONで返してください。\n"+ "性格: %s\n今見ている動画:\nタイトル: %s\n概要: %s", personaTone(persona), videoTitle, videoDescription, ) 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.NewContentFromParts([]*genai.Part{ genai.NewPartFromText(prompt), genai.NewPartFromBytes(audioBytes, mimeType), }, genai.RoleUser)) config := &genai.GenerateContentConfig{ ResponseMIMEType: "application/json", ResponseSchema: &genai.Schema{ Type: genai.TypeObject, Properties: map[string]*genai.Schema{ "transcript": {Type: genai.TypeString}, "reply": {Type: genai.TypeString}, }, Required: []string{"transcript", "reply"}, }, } resp, err := client.Models.GenerateContent(ctx, model, contents, config) if err != nil { return nil, err } var out voiceOutcome if err := json.Unmarshal([]byte(resp.Text()), &out); err != nil { return nil, fmt.Errorf("failed to parse voice response: %w", err) } return &out, nil }