動画URL表示・動画メタ注入・チャットUI・Gemma/Gemini切替の最小プロトタイプ

This commit is contained in:
tmk3ki 2026-07-22 01:29:15 +09:00
commit e8628e59a0
10 changed files with 682 additions and 0 deletions

3
.env.example Normal file
View file

@ -0,0 +1,3 @@
GEMINI_API_KEY=
YOUTUBE_API_KEY=
UNLOCK_CODE=

6
.gitignore vendored Normal file
View file

@ -0,0 +1,6 @@
/aibow
.env
*.mp4
*.mov
*.wav
/testdata/

12
Dockerfile Normal file
View 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
View 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
View 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
View 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
View 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
View 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
View 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
View 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
}