254 lines
9.4 KiB
Go
254 lines
9.4 KiB
Go
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"+
|
|
"- 具体的に何が映った・話されたかに基づく一言のみ許可(1文、短く)。\n"+
|
|
"- 特に語ることがない、映像に変化がない、さっきと同じ内容の繰り返しになる場合は、何も返さず空文字だけを返す。無理にコメントしなくていい。\n"+
|
|
"- 毎回喋る必要はない。むしろ喋らない方が普通。\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
|
|
}
|