検索UIをYouTube風PIPレイアウトに再設計、AI自律再生(play_video)、意図判別強化、動画切替時の自動反応、NDJSONストリーミングで状態表示
This commit is contained in:
parent
e5d55f59ef
commit
aff02e99a8
3 changed files with 305 additions and 123 deletions
124
gemini.go
124
gemini.go
|
|
@ -17,6 +17,9 @@ const (
|
||||||
modelGemini = "gemini-flash-latest"
|
modelGemini = "gemini-flash-latest"
|
||||||
|
|
||||||
searchFuncName = "search_youtube"
|
searchFuncName = "search_youtube"
|
||||||
|
playFuncName = "play_video"
|
||||||
|
|
||||||
|
maxToolIterations = 4
|
||||||
)
|
)
|
||||||
|
|
||||||
func selectModel(unlockCode, unlockSecret string) string {
|
func selectModel(unlockCode, unlockSecret string) string {
|
||||||
|
|
@ -41,20 +44,26 @@ func buildSystemInstruction(videoTitle, videoDescription, persona string) *genai
|
||||||
prompt := fmt.Sprintf(
|
prompt := fmt.Sprintf(
|
||||||
"あなたはユーザーと一緒に動画を見ている友達です。タメ口で短く、実況や相槌のように反応してください。\n"+
|
"あなたはユーザーと一緒に動画を見ている友達です。タメ口で短く、実況や相槌のように反応してください。\n"+
|
||||||
"性格: %s\n"+
|
"性格: %s\n"+
|
||||||
"今見ている動画:\nタイトル: %s\n概要: %s\n"+
|
"今見ている動画:\nタイトル: %s\n概要: %s\n\n"+
|
||||||
"ユーザーが「次の動画」「こういうの見たい」「〇〇のやつ見せて」のように次に見る動画を探してほしそうな時は"+
|
"動画検索・再生ツールの使い分けルール:\n"+
|
||||||
"%s関数でYouTubeを検索し、見つかったものから軽くおすすめして。",
|
"- ユーザーが今の動画の内容について話したり感想を言っているだけの時は、%sも%sも呼ばない。ただ会話する。\n"+
|
||||||
tone, videoTitle, videoDescription, searchFuncName,
|
"- ユーザーが「次の動画」「こういうの見たい」「〇〇のやつ見せて」のように次に見る動画を探してほしそうな時だけ%sを呼ぶ。\n"+
|
||||||
|
"- %sの結果に対してユーザーがまだ何も選んでいないなら、%sは呼ばずに候補を紹介して選んでもらう。\n"+
|
||||||
|
"- ユーザーが「任せる」「なんでもいいから」「それにして」「1個目で」のように選択をAIに委ねている、または検索結果から明確にどれかを選んだ時だけ%sを呼んで良い。\n"+
|
||||||
|
"- 判断に迷う時は再生せず、必ず候補を提示するだけにする(誤って動画を切り替えない方を優先)。",
|
||||||
|
tone, videoTitle, videoDescription,
|
||||||
|
searchFuncName, playFuncName, searchFuncName, searchFuncName, playFuncName, playFuncName,
|
||||||
)
|
)
|
||||||
return genai.NewContentFromText(prompt, genai.RoleUser)
|
return genai.NewContentFromText(prompt, genai.RoleUser)
|
||||||
}
|
}
|
||||||
|
|
||||||
func searchTool() *genai.Tool {
|
func chatTools() []*genai.Tool {
|
||||||
return &genai.Tool{
|
return []*genai.Tool{
|
||||||
|
{
|
||||||
FunctionDeclarations: []*genai.FunctionDeclaration{
|
FunctionDeclarations: []*genai.FunctionDeclaration{
|
||||||
{
|
{
|
||||||
Name: searchFuncName,
|
Name: searchFuncName,
|
||||||
Description: "ユーザーが次に見たい動画・関連動画・代替の動画を探すためにYouTubeを検索する。",
|
Description: "ユーザーが次に見たい動画・関連動画・代替の動画を探すためにYouTubeを検索し、候補を提示する。まだ動画は切り替えない。",
|
||||||
Parameters: &genai.Schema{
|
Parameters: &genai.Schema{
|
||||||
Type: genai.TypeObject,
|
Type: genai.TypeObject,
|
||||||
Properties: map[string]*genai.Schema{
|
Properties: map[string]*genai.Schema{
|
||||||
|
|
@ -63,6 +72,18 @@ func searchTool() *genai.Tool {
|
||||||
Required: []string{"query"},
|
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"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -70,38 +91,11 @@ func searchTool() *genai.Tool {
|
||||||
type chatOutcome struct {
|
type chatOutcome struct {
|
||||||
Reply string
|
Reply string
|
||||||
Videos []searchResult
|
Videos []searchResult
|
||||||
|
AutoPlayVideoID string
|
||||||
Model string
|
Model string
|
||||||
}
|
}
|
||||||
|
|
||||||
func chatReply(ctx context.Context, client *genai.Client, model, ytAPIKey, videoTitle, videoDescription, persona string, history []chatTurn, message string) (*chatOutcome, error) {
|
func searchResultsToFuncResponse(results []searchResult) map[string]any {
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
items := make([]map[string]any, len(results))
|
items := make([]map[string]any, len(results))
|
||||||
for i, r := range results {
|
for i, r := range results {
|
||||||
items[i] = map[string]any{
|
items[i] = map[string]any{
|
||||||
|
|
@ -110,16 +104,64 @@ func chatReply(ctx context.Context, client *genai.Client, model, ytAPIKey, video
|
||||||
"channelTitle": r.ChannelTitle,
|
"channelTitle": r.ChannelTitle,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
return map[string]any{"results": items}
|
||||||
|
}
|
||||||
|
|
||||||
if len(resp.Candidates) > 0 {
|
func chatReply(ctx context.Context, client *genai.Client, model, ytAPIKey, videoTitle, videoDescription, persona string, history []chatTurn, message string, onStatus func(string)) (*chatOutcome, error) {
|
||||||
contents = append(contents, resp.Candidates[0].Content)
|
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.NewContentFromFunctionResponse(call.Name, map[string]any{"results": items}, genai.RoleUser))
|
contents = append(contents, genai.NewContentFromText(message, genai.RoleUser))
|
||||||
|
|
||||||
resp2, err := client.Models.GenerateContent(ctx, model, contents, config)
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return &chatOutcome{Reply: resp2.Text(), Videos: results, Model: model}, nil
|
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")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
35
main.go
35
main.go
|
|
@ -85,10 +85,13 @@ type chatRequest struct {
|
||||||
Persona string `json:"persona"`
|
Persona string `json:"persona"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type chatResponse struct {
|
type chatStreamEvent struct {
|
||||||
Reply string `json:"reply"`
|
Type string `json:"type"` // "status" | "final" | "error"
|
||||||
Model string `json:"model"`
|
Text string `json:"text,omitempty"`
|
||||||
|
Reply string `json:"reply,omitempty"`
|
||||||
|
Model string `json:"model,omitempty"`
|
||||||
Videos []searchResult `json:"videos,omitempty"`
|
Videos []searchResult `json:"videos,omitempty"`
|
||||||
|
AutoPlayVideoID string `json:"autoPlayVideoId,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleChat(w http.ResponseWriter, r *http.Request) {
|
func handleChat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|
@ -102,15 +105,33 @@ func handleChat(w http.ResponseWriter, r *http.Request) {
|
||||||
return
|
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"))
|
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 {
|
if err != nil {
|
||||||
http.Error(w, err.Error(), http.StatusBadGateway)
|
writeEvent(chatStreamEvent{Type: "error", Text: err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
w.Header().Set("Content-Type", "application/json")
|
writeEvent(chatStreamEvent{
|
||||||
json.NewEncoder(w).Encode(chatResponse{Reply: outcome.Reply, Model: outcome.Model, Videos: outcome.Videos})
|
Type: "final",
|
||||||
|
Reply: outcome.Reply,
|
||||||
|
Model: outcome.Model,
|
||||||
|
Videos: outcome.Videos,
|
||||||
|
AutoPlayVideoID: outcome.AutoPlayVideoID,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
type unlockRequest struct {
|
type unlockRequest struct {
|
||||||
|
|
|
||||||
229
web/index.html
229
web/index.html
|
|
@ -79,31 +79,45 @@
|
||||||
}
|
}
|
||||||
.iconBtn:hover { filter: brightness(1.08); }
|
.iconBtn:hover { filter: brightness(1.08); }
|
||||||
|
|
||||||
.searchPanel {
|
.ytRow { display: flex; gap: 10px; padding: 8px; border-radius: 10px; cursor: pointer; align-items: flex-start; white-space: normal; }
|
||||||
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:hover { background: var(--surface-3); }
|
.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 img { width: 120px; aspect-ratio: 16/9; object-fit: cover; border-radius: 8px; background: #000; flex-shrink: 0; }
|
||||||
.ytRow .meta { min-width: 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 .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; }
|
.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 img { width: 90px; }
|
||||||
.inlineSearch .ytRow .title { font-size: 12px; }
|
.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 {
|
.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-md); overflow: hidden; box-shadow: var(--elevation-2);
|
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%; }
|
#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 {
|
.chatPane {
|
||||||
display: flex; flex-direction: column; min-width: 0;
|
display: flex; flex-direction: column; min-width: 0;
|
||||||
background: var(--surface); border-left: 1px solid var(--outline);
|
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 { 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.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.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); }
|
#chatForm { padding: 12px 16px 16px; border-top: 1px solid var(--outline); }
|
||||||
|
|
||||||
@media (max-width: 900px) {
|
@media (max-width: 900px) {
|
||||||
.app { grid-template-columns: 1fr; grid-template-rows: 1fr 340px; }
|
.app { grid-template-columns: 1fr; grid-template-rows: 1fr 340px; }
|
||||||
.chatPane { border-left: none; border-top: 1px solid var(--outline); }
|
.chatPane { border-left: none; border-top: 1px solid var(--outline); }
|
||||||
|
.playerWrap.pip { width: 180px; height: 101px; right: 12px; bottom: 12px; }
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
@ -147,9 +172,15 @@
|
||||||
<input id="urlInput" type="text" placeholder="YouTubeのURL・動画ID・検索キーワード">
|
<input id="urlInput" type="text" placeholder="YouTubeのURL・動画ID・検索キーワード">
|
||||||
<button id="loadBtn" class="iconBtn" title="読み込み/検索"><span class="material-symbols-outlined">search</span></button>
|
<button id="loadBtn" class="iconBtn" title="読み込み/検索"><span class="material-symbols-outlined">search</span></button>
|
||||||
</div>
|
</div>
|
||||||
<div id="searchPanel" class="searchPanel"></div>
|
|
||||||
<div class="playerStage">
|
<div class="playerStage">
|
||||||
<div class="playerWrap"><div id="player"></div></div>
|
<div id="searchStage" class="searchStage">
|
||||||
|
<div class="searchHeader">
|
||||||
|
<button id="searchCancelBtn" title="動画に戻る"><span class="material-symbols-outlined">arrow_back</span></button>
|
||||||
|
<span class="label">検索結果 - 動画をクリックして再生</span>
|
||||||
|
</div>
|
||||||
|
<div id="searchList" class="searchList"></div>
|
||||||
|
</div>
|
||||||
|
<div id="playerWrap" class="playerWrap"><div id="player"></div></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -230,30 +261,72 @@ async function loadVideoMeta(id) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function hideSearchPanel() {
|
function setPip(active) {
|
||||||
const box = document.getElementById('searchPanel');
|
document.getElementById('playerWrap').classList.toggle('pip', active);
|
||||||
box.classList.remove('show');
|
|
||||||
box.innerHTML = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function loadVideo(id) {
|
function showSearchStage(items) {
|
||||||
|
const list = document.getElementById('searchList');
|
||||||
|
list.innerHTML = '';
|
||||||
|
for (const item of (items || [])) {
|
||||||
|
list.appendChild(buildYtRow(item));
|
||||||
|
}
|
||||||
|
document.getElementById('searchStage').classList.add('show');
|
||||||
|
setPip(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function exitSearchMode() {
|
||||||
|
document.getElementById('searchStage').classList.remove('show');
|
||||||
|
setPip(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('playerWrap').addEventListener('click', () => {
|
||||||
|
if (document.getElementById('playerWrap').classList.contains('pip')) {
|
||||||
|
exitSearchMode();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('searchCancelBtn').addEventListener('click', exitSearchMode);
|
||||||
|
|
||||||
|
function loadVideo(id, opts) {
|
||||||
|
opts = opts || {};
|
||||||
|
const notifyAI = opts.notifyAI !== false;
|
||||||
if (player && player.loadVideoById) {
|
if (player && player.loadVideoById) {
|
||||||
player.loadVideoById(id);
|
player.loadVideoById(id);
|
||||||
}
|
}
|
||||||
loadVideoMeta(id);
|
exitSearchMode();
|
||||||
hideSearchPanel();
|
|
||||||
document.getElementById('urlInput').value = id;
|
document.getElementById('urlInput').value = id;
|
||||||
|
loadVideoMeta(id).then(() => {
|
||||||
|
if (notifyAI) {
|
||||||
|
sendChatMessage('(視聴中の動画が切り替わりました。新しい動画について一言リアクションして)', { showUserBubble: false });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const entityDecoder = document.createElement('textarea');
|
||||||
|
function decodeEntities(text) {
|
||||||
|
entityDecoder.innerHTML = text || '';
|
||||||
|
return entityDecoder.value;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildYtRow(item) {
|
function buildYtRow(item) {
|
||||||
const row = document.createElement('div');
|
const row = document.createElement('div');
|
||||||
row.className = 'ytRow';
|
row.className = 'ytRow';
|
||||||
row.innerHTML = `
|
const title = document.createElement('div');
|
||||||
<img src="${item.thumbnail}" alt="">
|
title.className = 'title';
|
||||||
<div class="meta">
|
title.textContent = decodeEntities(item.title);
|
||||||
<div class="title">${item.title}</div>
|
const channel = document.createElement('div');
|
||||||
<div class="channel">${item.channelTitle}</div>
|
channel.className = 'channel';
|
||||||
</div>`;
|
channel.textContent = decodeEntities(item.channelTitle);
|
||||||
|
const meta = document.createElement('div');
|
||||||
|
meta.className = 'meta';
|
||||||
|
meta.appendChild(title);
|
||||||
|
meta.appendChild(channel);
|
||||||
|
const img = document.createElement('img');
|
||||||
|
img.src = item.thumbnail;
|
||||||
|
img.alt = '';
|
||||||
|
row.appendChild(img);
|
||||||
|
row.appendChild(meta);
|
||||||
row.addEventListener('click', () => loadVideo(item.videoId));
|
row.addEventListener('click', () => loadVideo(item.videoId));
|
||||||
return row;
|
return row;
|
||||||
}
|
}
|
||||||
|
|
@ -263,12 +336,7 @@ async function runSearch(query) {
|
||||||
const res = await fetch('/api/search?q=' + encodeURIComponent(query));
|
const res = await fetch('/api/search?q=' + encodeURIComponent(query));
|
||||||
if (!res.ok) throw new Error('search failed');
|
if (!res.ok) throw new Error('search failed');
|
||||||
const items = await res.json();
|
const items = await res.json();
|
||||||
const box = document.getElementById('searchPanel');
|
showSearchStage(items);
|
||||||
box.innerHTML = '';
|
|
||||||
for (const item of (items || [])) {
|
|
||||||
box.appendChild(buildYtRow(item));
|
|
||||||
}
|
|
||||||
box.classList.add('show');
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error('search error', e);
|
console.error('search error', e);
|
||||||
}
|
}
|
||||||
|
|
@ -289,13 +357,6 @@ document.getElementById('urlInput').addEventListener('keydown', (e) => {
|
||||||
if (e.key === 'Enter') document.getElementById('loadBtn').click();
|
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 modelSelect = document.getElementById('modelSelect');
|
||||||
const lockIcon = document.getElementById('lockIcon');
|
const lockIcon = document.getElementById('lockIcon');
|
||||||
modelSelect.addEventListener('change', () => {
|
modelSelect.addEventListener('change', () => {
|
||||||
|
|
@ -333,10 +394,31 @@ document.getElementById('unlockBtn').addEventListener('click', async () => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
function appendBubble(role, text, videos) {
|
function appendBubble(role, text) {
|
||||||
const div = document.createElement('div');
|
const div = document.createElement('div');
|
||||||
div.className = 'bubble ' + role;
|
div.className = 'bubble ' + role;
|
||||||
div.textContent = text;
|
div.textContent = text;
|
||||||
|
document.getElementById('chatLog').appendChild(div);
|
||||||
|
scrollChatToBottom();
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function appendStatusBubble() {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'bubble model status';
|
||||||
|
div.innerHTML = '<span class="statusText">考え中</span><span class="dots"><span class="dot"></span><span class="dot"></span><span class="dot"></span></span>';
|
||||||
|
document.getElementById('chatLog').appendChild(div);
|
||||||
|
scrollChatToBottom();
|
||||||
|
return div;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setStatusText(div, text) {
|
||||||
|
div.querySelector('.statusText').textContent = text;
|
||||||
|
}
|
||||||
|
|
||||||
|
function finalizeBubble(div, text, videos) {
|
||||||
|
div.className = 'bubble model';
|
||||||
|
div.textContent = text;
|
||||||
if (videos && videos.length) {
|
if (videos && videos.length) {
|
||||||
const box = document.createElement('div');
|
const box = document.createElement('div');
|
||||||
box.className = 'inlineSearch';
|
box.className = 'inlineSearch';
|
||||||
|
|
@ -345,20 +427,22 @@ function appendBubble(role, text, videos) {
|
||||||
}
|
}
|
||||||
div.appendChild(box);
|
div.appendChild(box);
|
||||||
}
|
}
|
||||||
|
scrollChatToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollChatToBottom() {
|
||||||
const log = document.getElementById('chatLog');
|
const log = document.getElementById('chatLog');
|
||||||
log.appendChild(div);
|
|
||||||
log.scrollTop = log.scrollHeight;
|
log.scrollTop = log.scrollHeight;
|
||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('chatForm').addEventListener('submit', async (e) => {
|
async function sendChatMessage(message, opts) {
|
||||||
e.preventDefault();
|
opts = opts || {};
|
||||||
const input = document.getElementById('chatInput');
|
if (opts.showUserBubble !== false) {
|
||||||
const message = input.value.trim();
|
|
||||||
if (!message) return;
|
|
||||||
input.value = '';
|
|
||||||
appendBubble('user', message);
|
appendBubble('user', message);
|
||||||
|
}
|
||||||
|
const statusDiv = appendStatusBubble();
|
||||||
const wantsGemini = modelSelect.value === 'gemini';
|
const wantsGemini = modelSelect.value === 'gemini';
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/chat', {
|
const res = await fetch('/api/chat', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
|
|
@ -371,14 +455,49 @@ document.getElementById('chatForm').addEventListener('submit', async (e) => {
|
||||||
persona: document.getElementById('personaSelect').value,
|
persona: document.getElementById('personaSelect').value,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
if (!res.ok) throw new Error('chat failed: ' + res.status);
|
if (!res.ok || !res.body) throw new Error('chat failed: ' + res.status);
|
||||||
const data = await res.json();
|
|
||||||
appendBubble('model', data.reply, data.videos);
|
const reader = res.body.getReader();
|
||||||
|
const decoder = new TextDecoder();
|
||||||
|
let buf = '';
|
||||||
|
while (true) {
|
||||||
|
const { done, value } = await reader.read();
|
||||||
|
if (done) break;
|
||||||
|
buf += decoder.decode(value, { stream: true });
|
||||||
|
const lines = buf.split('\n');
|
||||||
|
buf = lines.pop();
|
||||||
|
for (const line of lines) {
|
||||||
|
if (!line.trim()) continue;
|
||||||
|
const evt = JSON.parse(line);
|
||||||
|
if (evt.type === 'status') {
|
||||||
|
setStatusText(statusDiv, evt.text);
|
||||||
|
} else if (evt.type === 'final') {
|
||||||
|
finalizeBubble(statusDiv, evt.reply, evt.videos);
|
||||||
chatHistory.push({ role: 'user', text: message });
|
chatHistory.push({ role: 'user', text: message });
|
||||||
chatHistory.push({ role: 'model', text: data.reply });
|
chatHistory.push({ role: 'model', text: evt.reply });
|
||||||
} catch (err) {
|
if (evt.videos && evt.videos.length) {
|
||||||
appendBubble('model', '(エラー: ' + err.message + ')');
|
setPip(true);
|
||||||
}
|
}
|
||||||
|
if (evt.autoPlayVideoId) {
|
||||||
|
loadVideo(evt.autoPlayVideoId, { notifyAI: false });
|
||||||
|
}
|
||||||
|
} else if (evt.type === 'error') {
|
||||||
|
finalizeBubble(statusDiv, '(エラー: ' + evt.text + ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
finalizeBubble(statusDiv, '(エラー: ' + err.message + ')');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('chatForm').addEventListener('submit', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = document.getElementById('chatInput');
|
||||||
|
const message = input.value.trim();
|
||||||
|
if (!message) return;
|
||||||
|
input.value = '';
|
||||||
|
sendChatMessage(message);
|
||||||
});
|
});
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue