動画URL表示・動画メタ注入・チャットUI・Gemma/Gemini切替の最小プロトタイプ
This commit is contained in:
commit
e8628e59a0
10 changed files with 682 additions and 0 deletions
3
.env.example
Normal file
3
.env.example
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
GEMINI_API_KEY=
|
||||||
|
YOUTUBE_API_KEY=
|
||||||
|
UNLOCK_CODE=
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,6 @@
|
||||||
|
/aibow
|
||||||
|
.env
|
||||||
|
*.mp4
|
||||||
|
*.mov
|
||||||
|
*.wav
|
||||||
|
/testdata/
|
||||||
12
Dockerfile
Normal file
12
Dockerfile
Normal file
|
|
@ -0,0 +1,12 @@
|
||||||
|
FROM golang:1.26 AS build
|
||||||
|
WORKDIR /src
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
COPY . .
|
||||||
|
RUN CGO_ENABLED=0 GOOS=linux go build -o /aibow .
|
||||||
|
|
||||||
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
COPY --from=build /aibow /aibow
|
||||||
|
ENV PORT=8080
|
||||||
|
EXPOSE 8080
|
||||||
|
ENTRYPOINT ["/aibow"]
|
||||||
32
env.go
Normal file
32
env.go
Normal file
|
|
@ -0,0 +1,32 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
func loadDotEnv(path string) {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(f)
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := strings.TrimSpace(scanner.Text())
|
||||||
|
if line == "" || strings.HasPrefix(line, "#") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k, v, ok := strings.Cut(line, "=")
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
k = strings.TrimSpace(k)
|
||||||
|
v = strings.TrimSpace(v)
|
||||||
|
if _, exists := os.LookupEnv(k); !exists {
|
||||||
|
os.Setenv(k, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
50
gemini.go
Normal file
50
gemini.go
Normal file
|
|
@ -0,0 +1,50 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"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"
|
||||||
|
)
|
||||||
|
|
||||||
|
func selectModel(unlockCode, unlockSecret string) string {
|
||||||
|
if unlockSecret != "" && unlockCode == unlockSecret {
|
||||||
|
return modelGemini
|
||||||
|
}
|
||||||
|
return modelGemma
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildSystemInstruction(videoTitle, videoDescription string) *genai.Content {
|
||||||
|
prompt := fmt.Sprintf(
|
||||||
|
"あなたはユーザーと一緒に動画を見ている友達です。タメ口で短く、実況や相槌のように反応してください。\n"+
|
||||||
|
"今見ている動画:\nタイトル: %s\n概要: %s",
|
||||||
|
videoTitle, videoDescription,
|
||||||
|
)
|
||||||
|
return genai.NewContentFromText(prompt, genai.RoleUser)
|
||||||
|
}
|
||||||
|
|
||||||
|
func chatReply(ctx context.Context, client *genai.Client, model, videoTitle, videoDescription string, history []chatTurn, message string) (string, 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{
|
||||||
|
SystemInstruction: buildSystemInstruction(videoTitle, videoDescription),
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return resp.Text(), nil
|
||||||
|
}
|
||||||
23
go.mod
Normal file
23
go.mod
Normal file
|
|
@ -0,0 +1,23 @@
|
||||||
|
module aibow
|
||||||
|
|
||||||
|
go 1.26.4
|
||||||
|
|
||||||
|
require (
|
||||||
|
cloud.google.com/go v0.116.0 // indirect
|
||||||
|
cloud.google.com/go/auth v0.9.3 // indirect
|
||||||
|
cloud.google.com/go/compute/metadata v0.5.0 // indirect
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da // indirect
|
||||||
|
github.com/google/go-cmp v0.6.0 // indirect
|
||||||
|
github.com/google/s2a-go v0.1.8 // indirect
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4 // indirect
|
||||||
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
go.opencensus.io v0.24.0 // indirect
|
||||||
|
golang.org/x/crypto v0.36.0 // indirect
|
||||||
|
golang.org/x/net v0.38.0 // indirect
|
||||||
|
golang.org/x/sys v0.31.0 // indirect
|
||||||
|
golang.org/x/text v0.23.0 // indirect
|
||||||
|
google.golang.org/genai v1.64.0 // indirect
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 // indirect
|
||||||
|
google.golang.org/grpc v1.66.2 // indirect
|
||||||
|
google.golang.org/protobuf v1.34.2 // indirect
|
||||||
|
)
|
||||||
124
go.sum
Normal file
124
go.sum
Normal file
|
|
@ -0,0 +1,124 @@
|
||||||
|
cloud.google.com/go v0.26.0/go.mod h1:aQUYkXzVsufM+DwF1aE+0xfcU+56JwCaLick0ClmMTw=
|
||||||
|
cloud.google.com/go v0.116.0 h1:B3fRrSDkLRt5qSHWe40ERJvhvnQwdZiHu0bJOpldweE=
|
||||||
|
cloud.google.com/go v0.116.0/go.mod h1:cEPSRWPzZEswwdr9BxE6ChEn01dWlTaF05LiC2Xs70U=
|
||||||
|
cloud.google.com/go/auth v0.9.3 h1:VOEUIAADkkLtyfr3BLa3R8Ed/j6w1jTBmARx+wb5w5U=
|
||||||
|
cloud.google.com/go/auth v0.9.3/go.mod h1:7z6VY+7h3KUdRov5F1i8NDP5ZzWKYmEPO842BgCsmTk=
|
||||||
|
cloud.google.com/go/compute/metadata v0.5.0 h1:Zr0eK8JbFv6+Wi4ilXAR8FJ3wyNdpxHKJNPos6LTZOY=
|
||||||
|
cloud.google.com/go/compute/metadata v0.5.0/go.mod h1:aHnloV2TPI38yx4s9+wAZhHykWvVCfu7hQbF+9CWoiY=
|
||||||
|
github.com/BurntSushi/toml v0.3.1/go.mod h1:xHWCNGjB5oqiDr8zfno3MHue2Ht5sIBksp03qcyfWMU=
|
||||||
|
github.com/census-instrumentation/opencensus-proto v0.2.1/go.mod h1:f6KPmirojxKA12rnyqOA5BBL4O983OfeGPqjHWSTneU=
|
||||||
|
github.com/client9/misspell v0.3.4/go.mod h1:qj6jICC3Q7zFZvVWo7KLAzC3yx5G7kyvSDkc90ppPyw=
|
||||||
|
github.com/cncf/udpa/go v0.0.0-20191209042840-269d4d468f6f/go.mod h1:M8M6+tZqaGXZJjfX53e64911xZQV5JYwmTeXPW+k8Sc=
|
||||||
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.0/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.1-0.20191026205805-5f8ba28d4473/go.mod h1:YTl/9mNaCwkRvm6d1a2C3ymFceY/DCBVvsKhRF0iEA4=
|
||||||
|
github.com/envoyproxy/go-control-plane v0.9.4/go.mod h1:6rpuAdCZL397s3pYoYcLgu1mIlRU8Am5FuJP05cCM98=
|
||||||
|
github.com/envoyproxy/protoc-gen-validate v0.1.0/go.mod h1:iSmxcyjqTsJpI2R4NaDN7+kN2VEUnK/pcBlmesArF7c=
|
||||||
|
github.com/golang/glog v0.0.0-20160126235308-23def4e6c14b/go.mod h1:SBH7ygxi8pfUlaOkMMuAQtPIUF8ecWP5IEl/CR7VP2Q=
|
||||||
|
github.com/golang/groupcache v0.0.0-20200121045136-8c9f03a8e57e/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da h1:oI5xCqsCo564l8iNU+DwB5epxmsaqB+rhGL0m5jtYqE=
|
||||||
|
github.com/golang/groupcache v0.0.0-20210331224755-41bb18bfe9da/go.mod h1:cIg4eruTrX1D+g88fzRXU5OdNfaM+9IcxsU14FzY7Hc=
|
||||||
|
github.com/golang/mock v1.1.1/go.mod h1:oTYuIxOrZwtPieC+H1uAHpcLFnEyAGVDL/k47Jfbm0A=
|
||||||
|
github.com/golang/protobuf v1.2.0/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.3.2/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1/go.mod h1:ceaxUfeHdC40wWswd/P6IGgMaK3YpKi5j83Wpe3EHw8=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.1.0.20200221234624-67d41d38c208/go.mod h1:xKAWHe0F5eneWXFV3EuXVDTCmh+JuBKY0li0aMyXATA=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.2/go.mod h1:LlEzMj4AhA7rCAGe4KMBDvJI+AwstrUpVNzEA03Pprs=
|
||||||
|
github.com/golang/protobuf v1.4.0-rc.4.0.20200313231945-b860323f09d0/go.mod h1:WU3c8KckQ9AFe+yFwt9sWVRKCVIyN9cPHBJSNnbL67w=
|
||||||
|
github.com/golang/protobuf v1.4.0/go.mod h1:jodUvKwWbYaEsadDk5Fwe5c77LiNKVO9IDvqG2KuDX0=
|
||||||
|
github.com/golang/protobuf v1.4.1/go.mod h1:U8fpvMrcmy5pZrNK1lt4xCsGvpyWQ/VVv6QDs8UjoX8=
|
||||||
|
github.com/golang/protobuf v1.4.3/go.mod h1:oDoupMAO8OvCJWAcko0GGGIgR6R6ocIYbsSw735rRwI=
|
||||||
|
github.com/google/go-cmp v0.2.0/go.mod h1:oXzfMopK8JAjlY9xF4vHSVASa0yLyX7SntLO5aqRK0M=
|
||||||
|
github.com/google/go-cmp v0.3.0/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.3.1/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU=
|
||||||
|
github.com/google/go-cmp v0.4.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.0/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.5.3/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
|
github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI=
|
||||||
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
|
github.com/google/s2a-go v0.1.8 h1:zZDs9gcbt9ZPLV0ndSyQk6Kacx2g/X+SKYovpnz3SMM=
|
||||||
|
github.com/google/s2a-go v0.1.8/go.mod h1:6iNWHTpQ+nfNRN5E00MSdfDwVesa8hhS32PhPO8deJA=
|
||||||
|
github.com/google/uuid v1.1.2/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4 h1:XYIDZApgAnrN1c855gTgghdIA6Stxb52D5RnLI1SLyw=
|
||||||
|
github.com/googleapis/enterprise-certificate-proxy v0.3.4/go.mod h1:YKe7cfqYXjKGpGvmSg28/fFvhNzinZQm8DGnaburhGA=
|
||||||
|
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
|
||||||
|
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
|
github.com/prometheus/client_model v0.0.0-20190812154241-14fe0d1b01d4/go.mod h1:xMI15A0UPsDsEKsMN9yxemIoYk6Tm2C1GtYGdfGttqA=
|
||||||
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
|
go.opencensus.io v0.24.0 h1:y73uSU6J157QMP2kn2r30vwW1A2W2WFwSCGnAVxeaD0=
|
||||||
|
go.opencensus.io v0.24.0/go.mod h1:vNK8G9p7aAivkbmorf4v+7Hgx+Zs0yY+0fOtgBfjQKo=
|
||||||
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
|
golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPhUkYOs5KpL4U8rLKemX1yGLhDgUto=
|
||||||
|
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
||||||
|
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
||||||
|
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
|
||||||
|
golang.org/x/lint v0.0.0-20181026193005-c67002cb31c3/go.mod h1:UVdnD1Gm6xHRNCYTkRU2/jEulfH38KcIWyp/GAMgvoE=
|
||||||
|
golang.org/x/lint v0.0.0-20190227174305-5b3e6a55c961/go.mod h1:wehouNa3lNwaWXcvxsM5YxQ5yQlVC4a0KAMCusXpPoU=
|
||||||
|
golang.org/x/lint v0.0.0-20190313153728-d0100b6bd8b3/go.mod h1:6SW0HCj/g11FgYtHlgUYUwCkIfeOF89ocIRzGO/8vkc=
|
||||||
|
golang.org/x/net v0.0.0-20180724234803-3673e40ba225/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20180826012351-8a410e7b638d/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190213061140-3a22650c66bd/go.mod h1:mL1N/T3taQHkDXs73rZJwtUhF3w3ftmwwsq0BUmARs4=
|
||||||
|
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||||
|
golang.org/x/net v0.0.0-20201110031124-69a78807bb2b/go.mod h1:sp8m0HH+o8qH0wwXwYZr8TS3Oi6o0r6Gce1SSxlDquU=
|
||||||
|
golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8=
|
||||||
|
golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8=
|
||||||
|
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
|
||||||
|
golang.org/x/sync v0.0.0-20180314180146-1d60e4601c6f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20181108010431-42b317875d0f/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
|
golang.org/x/sys v0.0.0-20180830151530-49385e6e1522/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
|
||||||
|
golang.org/x/sys v0.0.0-20190412213103-97732733099d/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.0.0-20200930185726-fdedc70b468f/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||||
|
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
||||||
|
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
|
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
||||||
|
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
||||||
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190114222345-bf090417da8b/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
|
golang.org/x/tools v0.0.0-20190226205152-f727befe758c/go.mod h1:9Yl7xja0Znq3iFh3HoIrodX9oNMXvdceNzlUR8zjMvY=
|
||||||
|
golang.org/x/tools v0.0.0-20190311212946-11955173bddd/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs=
|
||||||
|
golang.org/x/tools v0.0.0-20190524140312-2c0ae7006135/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q=
|
||||||
|
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
|
google.golang.org/appengine v1.1.0/go.mod h1:EbEs0AVv82hx2wNQdGPgUI5lhzA/G0D9YwlJXL52JkM=
|
||||||
|
google.golang.org/appengine v1.4.0/go.mod h1:xpcJRLb0r/rnEns0DIKYYv+WjYCduHsrkT7/EB5XEv4=
|
||||||
|
google.golang.org/genai v1.64.0 h1:Yb+Y3tL8EIh6LFBibC7xUgxAFb98l34y7byOcBBYNho=
|
||||||
|
google.golang.org/genai v1.64.0/go.mod h1:mDdPDFXo1Ats7f1WXVyZgWb/CkMzFWTWJruIMy7hGIU=
|
||||||
|
google.golang.org/genproto v0.0.0-20180817151627-c66870c02cf8/go.mod h1:JiN7NxoALGmiZfu7CAH4rXhgtRTLTxftemlI0sWmxmc=
|
||||||
|
google.golang.org/genproto v0.0.0-20190819201941-24fa4b261c55/go.mod h1:DMBHOl98Agz4BDEuKkezgsaosCRResVns1a3J2ZsMNc=
|
||||||
|
google.golang.org/genproto v0.0.0-20200526211855-cb27e3aa2013/go.mod h1:NbSheEEYHJ7i3ixzK3sjbqSGDJWnxyFXZblF3eUsNvo=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1 h1:pPJltXNxVzT4pK9yD8vR9X75DaWYYmLGMsEvBfFQZzQ=
|
||||||
|
google.golang.org/genproto/googleapis/rpc v0.0.0-20240903143218-8af14fe29dc1/go.mod h1:UqMtugtsSgubUsoxbuAoiCXvqvErP7Gf0so0mK9tHxU=
|
||||||
|
google.golang.org/grpc v1.19.0/go.mod h1:mqu4LbDTu4XGKhr4mRzUsmM4RtVoemTSY81AxZiDr8c=
|
||||||
|
google.golang.org/grpc v1.23.0/go.mod h1:Y5yQAOtifL1yxbo5wqy6BxZv8vAUGQwXBOALyacEbxg=
|
||||||
|
google.golang.org/grpc v1.25.1/go.mod h1:c3i+UQWmh7LiEpx4sFZnkU36qjEYZ0imhYfXVyQciAY=
|
||||||
|
google.golang.org/grpc v1.27.0/go.mod h1:qbnxyOmOxrQa7FizSgH+ReBfzJrCY1pSN7KXBS8abTk=
|
||||||
|
google.golang.org/grpc v1.33.2/go.mod h1:JMHMWHQWaTccqQQlmk3MJZS+GWXOdAesneDmEnv2fbc=
|
||||||
|
google.golang.org/grpc v1.66.2 h1:3QdXkuq3Bkh7w+ywLdLvM56cmGvQHUMZpiCzt6Rqaoo=
|
||||||
|
google.golang.org/grpc v1.66.2/go.mod h1:s3/l6xSSCURdVfAnL+TqCNMyTDAGN6+lZeVxnZR128Y=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200109180630-ec00e32a8dfd/go.mod h1:DFci5gLYBciE7Vtevhsrf46CRTquxDuWsQurQQe4oz8=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200221191635-4d8936d0db64/go.mod h1:kwYJMbMJ01Woi6D6+Kah6886xMZcty6N08ah7+eCXa0=
|
||||||
|
google.golang.org/protobuf v0.0.0-20200228230310-ab0ca4ff8a60/go.mod h1:cfTl7dwQJ+fmap5saPgwCLgHXTUD7jkjRqWcaiX5VyM=
|
||||||
|
google.golang.org/protobuf v1.20.1-0.20200309200217-e05f789c0967/go.mod h1:A+miEFZTKqfCUM6K7xSMQL9OKL/b6hQv+e19PK+JZNE=
|
||||||
|
google.golang.org/protobuf v1.21.0/go.mod h1:47Nbq4nVaFHyn7ilMalzfO3qCViNmqZ2kzikPIcrTAo=
|
||||||
|
google.golang.org/protobuf v1.22.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.0/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.23.1-0.20200526195155-81db48ad09cc/go.mod h1:EGpADcykh3NcUnDUJcl1+ZksZNG86OlYog2l/sGQquU=
|
||||||
|
google.golang.org/protobuf v1.25.0/go.mod h1:9JNX74DMeImyA3h4bdi1ymwjUzf21/xIlbajtzgsN7c=
|
||||||
|
google.golang.org/protobuf v1.34.2 h1:6xV6lTsCfpGD21XK49h7MhtcApnLqkfYgPcdHftf6hg=
|
||||||
|
google.golang.org/protobuf v1.34.2/go.mod h1:qYOHts0dSfpeUzUFpOMr/WGzszTmLH+DiWniOlNbLDw=
|
||||||
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
honnef.co/go/tools v0.0.0-20190102054323-c2f93a96b099/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
|
honnef.co/go/tools v0.0.0-20190523083050-ea95bdfd59fc/go.mod h1:rf3lG4BRIbNafJWhAfAdb/ePZxsR/4RtNHQocxwk9r4=
|
||||||
120
main.go
Normal file
120
main.go
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"log"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"google.golang.org/genai"
|
||||||
|
)
|
||||||
|
|
||||||
|
var geminiClient *genai.Client
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
loadDotEnv(".env")
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
client, err := genai.NewClient(ctx, &genai.ClientConfig{
|
||||||
|
APIKey: os.Getenv("GEMINI_API_KEY"),
|
||||||
|
Backend: genai.BackendGeminiAPI,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
log.Fatalf("failed to create genai client: %v", err)
|
||||||
|
}
|
||||||
|
geminiClient = client
|
||||||
|
|
||||||
|
port := os.Getenv("PORT")
|
||||||
|
if port == "" {
|
||||||
|
port = "8080"
|
||||||
|
}
|
||||||
|
|
||||||
|
http.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
w.Write([]byte("ok"))
|
||||||
|
})
|
||||||
|
http.HandleFunc("/api/video-meta", handleVideoMeta)
|
||||||
|
http.HandleFunc("/api/chat", handleChat)
|
||||||
|
http.HandleFunc("/api/unlock", handleUnlock)
|
||||||
|
http.Handle("/", http.FileServer(http.Dir("web")))
|
||||||
|
|
||||||
|
log.Printf("listening on :%s", port)
|
||||||
|
if err := http.ListenAndServe(":"+port, nil); err != nil {
|
||||||
|
log.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleVideoMeta(w http.ResponseWriter, r *http.Request) {
|
||||||
|
id := r.URL.Query().Get("id")
|
||||||
|
if id == "" {
|
||||||
|
http.Error(w, "missing id", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
meta, err := fetchVideoMeta(id, os.Getenv("YOUTUBE_API_KEY"))
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), http.StatusBadGateway)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(meta)
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatRequest struct {
|
||||||
|
VideoTitle string `json:"videoTitle"`
|
||||||
|
VideoDescription string `json:"videoDescription"`
|
||||||
|
History []chatTurn `json:"history"`
|
||||||
|
Message string `json:"message"`
|
||||||
|
UnlockCode string `json:"unlockCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type chatResponse struct {
|
||||||
|
Reply string `json:"reply"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleChat(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req chatRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
model := selectModel(req.UnlockCode, os.Getenv("UNLOCK_CODE"))
|
||||||
|
reply, err := chatReply(r.Context(), geminiClient, model, req.VideoTitle, req.VideoDescription, 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})
|
||||||
|
}
|
||||||
|
|
||||||
|
type unlockRequest struct {
|
||||||
|
Code string `json:"code"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type unlockResponse struct {
|
||||||
|
Ok bool `json:"ok"`
|
||||||
|
Model string `json:"model"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleUnlock(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var req unlockRequest
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||||
|
http.Error(w, "bad request", http.StatusBadRequest)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
model := selectModel(req.Code, os.Getenv("UNLOCK_CODE"))
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(unlockResponse{Ok: model == modelGemini, Model: model})
|
||||||
|
}
|
||||||
260
web/index.html
Normal file
260
web/index.html
Normal file
|
|
@ -0,0 +1,260 @@
|
||||||
|
<!doctype html>
|
||||||
|
<html lang="ja">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<title>aibow</title>
|
||||||
|
<style>
|
||||||
|
:root {
|
||||||
|
--bg: #0b0c10;
|
||||||
|
--panel: rgba(255,255,255,0.05);
|
||||||
|
--panel-border: rgba(255,255,255,0.08);
|
||||||
|
--text: #e9ebf0;
|
||||||
|
--text-dim: #9096a3;
|
||||||
|
--accent: #6c8bff;
|
||||||
|
--accent-2: #8f6cff;
|
||||||
|
--bubble-user: linear-gradient(135deg, #6c8bff, #8f6cff);
|
||||||
|
--bubble-model: rgba(255,255,255,0.07);
|
||||||
|
--radius: 16px;
|
||||||
|
}
|
||||||
|
* { box-sizing: border-box; }
|
||||||
|
html, body { height: 100%; }
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: "Hiragino Kaku Gothic ProN", "Yu Gothic", system-ui, sans-serif;
|
||||||
|
background: radial-gradient(1200px 800px at 20% -10%, #17193a 0%, var(--bg) 60%);
|
||||||
|
color: var(--text);
|
||||||
|
}
|
||||||
|
.app { display: grid; grid-template-columns: 1fr 360px; height: 100vh; }
|
||||||
|
.videoPane { display: flex; flex-direction: column; min-width: 0; }
|
||||||
|
.urlBar { display: flex; gap: 8px; padding: 14px 16px; }
|
||||||
|
.urlBar input {
|
||||||
|
flex: 1; padding: 10px 14px; border-radius: 999px; border: 1px solid var(--panel-border);
|
||||||
|
background: var(--panel); color: var(--text); outline: none; font-size: 14px;
|
||||||
|
}
|
||||||
|
.urlBar input:focus { border-color: var(--accent); }
|
||||||
|
.urlBar button {
|
||||||
|
padding: 10px 20px; border-radius: 999px; border: none; cursor: pointer;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #fff; font-weight: 600;
|
||||||
|
}
|
||||||
|
.playerStage { flex: 1; display: flex; align-items: center; justify-content: center; padding: 0 16px 16px; }
|
||||||
|
.playerWrap {
|
||||||
|
position: relative; width: 100%; max-width: 1100px; aspect-ratio: 16/9;
|
||||||
|
background: #000; border-radius: var(--radius); overflow: hidden;
|
||||||
|
box-shadow: 0 20px 60px rgba(0,0,0,0.5);
|
||||||
|
}
|
||||||
|
#player { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||||
|
|
||||||
|
.chatPane {
|
||||||
|
display: flex; flex-direction: column; min-width: 0;
|
||||||
|
background: var(--panel); border-left: 1px solid var(--panel-border);
|
||||||
|
backdrop-filter: blur(12px);
|
||||||
|
}
|
||||||
|
.chatHeader { padding: 16px; border-bottom: 1px solid var(--panel-border); }
|
||||||
|
.chatHeader .name { font-weight: 700; font-size: 15px; display: flex; align-items: center; gap: 6px; }
|
||||||
|
.modelBadge {
|
||||||
|
margin-top: 8px; display: inline-flex; align-items: center; gap: 6px;
|
||||||
|
padding: 4px 10px; border-radius: 999px; font-size: 12px; cursor: pointer;
|
||||||
|
border: 1px solid var(--panel-border); color: var(--text-dim); user-select: none;
|
||||||
|
}
|
||||||
|
.modelBadge.unlocked { color: #ffd479; border-color: rgba(255,212,121,0.4); }
|
||||||
|
.unlockRow { display: none; gap: 6px; margin-top: 8px; }
|
||||||
|
.unlockRow.show { display: flex; }
|
||||||
|
.unlockRow input {
|
||||||
|
flex: 1; padding: 6px 10px; border-radius: 8px; border: 1px solid var(--panel-border);
|
||||||
|
background: rgba(0,0,0,0.3); color: var(--text); font-size: 12px; outline: none;
|
||||||
|
}
|
||||||
|
.unlockRow button {
|
||||||
|
padding: 6px 10px; border-radius: 8px; border: none; background: var(--accent); color: #fff;
|
||||||
|
font-size: 12px; cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#chatLog { flex: 1; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; padding: 16px; }
|
||||||
|
.bubble { padding: 10px 14px; border-radius: var(--radius); max-width: 88%; white-space: pre-wrap; font-size: 14px; line-height: 1.5; }
|
||||||
|
.bubble.user { align-self: flex-end; background: var(--bubble-user); color: #fff; border-bottom-right-radius: 4px; }
|
||||||
|
.bubble.model { align-self: flex-start; background: var(--bubble-model); border-bottom-left-radius: 4px; }
|
||||||
|
|
||||||
|
#chatForm { display: flex; gap: 8px; padding: 12px 16px 16px; border-top: 1px solid var(--panel-border); }
|
||||||
|
#chatForm input {
|
||||||
|
flex: 1; padding: 10px 14px; border-radius: 999px; border: 1px solid var(--panel-border);
|
||||||
|
background: rgba(0,0,0,0.25); color: var(--text); outline: none; font-size: 14px;
|
||||||
|
}
|
||||||
|
#chatForm input:focus { border-color: var(--accent); }
|
||||||
|
#chatForm button {
|
||||||
|
padding: 10px 18px; border-radius: 999px; border: none; cursor: pointer;
|
||||||
|
background: linear-gradient(135deg, var(--accent), var(--accent-2)); color: #fff; font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 860px) {
|
||||||
|
.app { grid-template-columns: 1fr; grid-template-rows: 1fr 320px; }
|
||||||
|
.chatPane { border-left: none; border-top: 1px solid var(--panel-border); }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div class="app">
|
||||||
|
<div class="videoPane">
|
||||||
|
<div class="urlBar">
|
||||||
|
<input id="urlInput" type="text" placeholder="YouTubeのURLまたは動画IDを入力">
|
||||||
|
<button id="loadBtn">読み込む</button>
|
||||||
|
</div>
|
||||||
|
<div class="playerStage">
|
||||||
|
<div class="playerWrap"><div id="player"></div></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="chatPane">
|
||||||
|
<div class="chatHeader">
|
||||||
|
<div class="name">🐾 aibow</div>
|
||||||
|
<div id="modelBadge" class="modelBadge">🔓 Gemma</div>
|
||||||
|
<div id="unlockRow" class="unlockRow">
|
||||||
|
<input id="unlockInput" type="text" placeholder="解放コード">
|
||||||
|
<button id="unlockBtn">解放</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div id="chatLog"></div>
|
||||||
|
<form id="chatForm">
|
||||||
|
<input id="chatInput" type="text" placeholder="ひとこと話しかける(暫定:打ち込み式)" autocomplete="off">
|
||||||
|
<button type="submit">送信</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script src="https://www.youtube.com/iframe_api"></script>
|
||||||
|
<script>
|
||||||
|
let player;
|
||||||
|
|
||||||
|
function extractVideoId(input) {
|
||||||
|
input = input.trim();
|
||||||
|
if (/^[\w-]{11}$/.test(input)) return input;
|
||||||
|
try {
|
||||||
|
const u = new URL(input);
|
||||||
|
if (u.hostname.includes('youtu.be')) return u.pathname.slice(1);
|
||||||
|
if (u.searchParams.get('v')) return u.searchParams.get('v');
|
||||||
|
const liveMatch = u.pathname.match(/\/(live|embed|shorts)\/([\w-]{11})/);
|
||||||
|
if (liveMatch) return liveMatch[2];
|
||||||
|
} catch (e) {}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function onYouTubeIframeAPIReady() {
|
||||||
|
player = new YT.Player('player', {
|
||||||
|
height: '100%',
|
||||||
|
width: '100%',
|
||||||
|
playerVars: { autoplay: 1 },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let videoTitle = '';
|
||||||
|
let videoDescription = '';
|
||||||
|
let chatHistory = [];
|
||||||
|
let unlockCode = '';
|
||||||
|
let unlocked = false;
|
||||||
|
|
||||||
|
async function loadVideoMeta(id) {
|
||||||
|
videoTitle = '';
|
||||||
|
videoDescription = '';
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/video-meta?id=' + encodeURIComponent(id));
|
||||||
|
if (!res.ok) throw new Error('meta fetch failed');
|
||||||
|
const meta = await res.json();
|
||||||
|
videoTitle = meta.title || '';
|
||||||
|
videoDescription = meta.description || '';
|
||||||
|
} catch (e) {
|
||||||
|
console.error('video meta error', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('loadBtn').addEventListener('click', () => {
|
||||||
|
const id = extractVideoId(document.getElementById('urlInput').value);
|
||||||
|
if (!id) { alert('動画IDが読み取れませんでした'); return; }
|
||||||
|
if (player && player.loadVideoById) {
|
||||||
|
player.loadVideoById(id);
|
||||||
|
}
|
||||||
|
loadVideoMeta(id);
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('urlInput').addEventListener('keydown', (e) => {
|
||||||
|
if (e.key === 'Enter') document.getElementById('loadBtn').click();
|
||||||
|
});
|
||||||
|
|
||||||
|
function setModelBadge(isUnlocked) {
|
||||||
|
unlocked = isUnlocked;
|
||||||
|
const badge = document.getElementById('modelBadge');
|
||||||
|
if (isUnlocked) {
|
||||||
|
badge.textContent = '🔓 Gemini';
|
||||||
|
badge.classList.add('unlocked');
|
||||||
|
} else {
|
||||||
|
badge.textContent = '🔓 Gemma (タップでGemini解放)';
|
||||||
|
badge.classList.remove('unlocked');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
setModelBadge(false);
|
||||||
|
|
||||||
|
document.getElementById('modelBadge').addEventListener('click', () => {
|
||||||
|
document.getElementById('unlockRow').classList.toggle('show');
|
||||||
|
});
|
||||||
|
|
||||||
|
document.getElementById('unlockBtn').addEventListener('click', async () => {
|
||||||
|
const code = document.getElementById('unlockInput').value.trim();
|
||||||
|
if (!code) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/unlock', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ code }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (data.ok) {
|
||||||
|
unlockCode = code;
|
||||||
|
document.getElementById('modelBadge').textContent = '🔓 Gemini';
|
||||||
|
document.getElementById('modelBadge').classList.add('unlocked');
|
||||||
|
document.getElementById('unlockRow').classList.remove('show');
|
||||||
|
} else {
|
||||||
|
alert('コードが違います');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
alert('解放に失敗しました');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function appendBubble(role, text) {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'bubble ' + role;
|
||||||
|
div.textContent = text;
|
||||||
|
const log = document.getElementById('chatLog');
|
||||||
|
log.appendChild(div);
|
||||||
|
log.scrollTop = log.scrollHeight;
|
||||||
|
}
|
||||||
|
|
||||||
|
document.getElementById('chatForm').addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const input = document.getElementById('chatInput');
|
||||||
|
const message = input.value.trim();
|
||||||
|
if (!message) return;
|
||||||
|
input.value = '';
|
||||||
|
appendBubble('user', message);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/chat', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
videoTitle, videoDescription,
|
||||||
|
history: chatHistory,
|
||||||
|
message,
|
||||||
|
unlockCode,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error('chat failed: ' + res.status);
|
||||||
|
const data = await res.json();
|
||||||
|
appendBubble('model', data.reply);
|
||||||
|
chatHistory.push({ role: 'user', text: message });
|
||||||
|
chatHistory.push({ role: 'model', text: data.reply });
|
||||||
|
} catch (err) {
|
||||||
|
appendBubble('model', '(エラー: ' + err.message + ')');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
52
youtube.go
Normal file
52
youtube.go
Normal file
|
|
@ -0,0 +1,52 @@
|
||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"net/url"
|
||||||
|
)
|
||||||
|
|
||||||
|
type videoMeta struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ytVideosResponse struct {
|
||||||
|
Items []struct {
|
||||||
|
Snippet struct {
|
||||||
|
Title string `json:"title"`
|
||||||
|
Description string `json:"description"`
|
||||||
|
} `json:"snippet"`
|
||||||
|
} `json:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func fetchVideoMeta(videoID, apiKey string) (*videoMeta, error) {
|
||||||
|
q := url.Values{}
|
||||||
|
q.Set("part", "snippet")
|
||||||
|
q.Set("id", videoID)
|
||||||
|
q.Set("key", apiKey)
|
||||||
|
|
||||||
|
resp, err := http.Get("https://www.googleapis.com/youtube/v3/videos?" + q.Encode())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
if resp.StatusCode != http.StatusOK {
|
||||||
|
return nil, fmt.Errorf("youtube api status %d", resp.StatusCode)
|
||||||
|
}
|
||||||
|
|
||||||
|
var parsed ytVideosResponse
|
||||||
|
if err := json.NewDecoder(resp.Body).Decode(&parsed); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(parsed.Items) == 0 {
|
||||||
|
return nil, fmt.Errorf("video not found: %s", videoID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &videoMeta{
|
||||||
|
Title: parsed.Items[0].Snippet.Title,
|
||||||
|
Description: parsed.Items[0].Snippet.Description,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue