refactor: rewrite backend in Go, replacing Python FastAPI

Port the FastAPI backend to Go 1.26, preserving all API functionality:
- auth (register/login/guest/logout/profile/history) with JWT + bcrypt
- scripts (list/detail) backed by embedded sample_data.json
- games (create/join/start/chat/phase/clue/vote/state/replay) + WebSocket
- rule-based NPC agent plus DeepSeek/Ollama AI backends
- SQLite persistence via modernc.org/sqlite (pure Go, no cgo)

Frontend and documents are unchanged.
This commit is contained in:
gmh01
2026-08-03 11:39:53 +08:00
parent c176c04aab
commit 7614e79d12
43 changed files with 2498 additions and 1462 deletions
+322
View File
@@ -0,0 +1,322 @@
package agents
import (
"bytes"
"encoding/json"
"net/http"
"strings"
"mmgame/internal/config"
"mmgame/internal/scripts"
)
type ChatMessage struct {
Role string `json:"role"`
Content string `json:"content"`
}
type AIBackend struct {
backend string
}
var ai = &AIBackend{backend: config.Cfg.AIBackend}
func (a *AIBackend) chat(messages []ChatMessage, temperature float64, maxTokens int) string {
switch a.backend {
case "ollama":
return a.ollamaChat(messages, temperature, maxTokens)
case "deepseek":
return a.deepseekChat(messages, temperature, maxTokens)
default:
return ""
}
}
func (a *AIBackend) ollamaChat(messages []ChatMessage, temperature float64, maxTokens int) string {
body := map[string]interface{}{
"model": config.Cfg.OllamaModel,
"messages": messages,
"stream": false,
"options": map[string]interface{}{"temperature": temperature, "num_predict": maxTokens},
}
data, err := json.Marshal(body)
if err != nil {
return ""
}
resp, err := http.Post(config.Cfg.OllamaBaseURL+"/api/chat", "application/json", bytes.NewReader(data))
if err != nil {
return ""
}
defer resp.Body.Close()
var result struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return ""
}
return result.Message.Content
}
func (a *AIBackend) deepseekChat(messages []ChatMessage, temperature float64, maxTokens int) string {
body := map[string]interface{}{
"model": config.Cfg.DeepSeekModel,
"messages": messages,
"temperature": temperature,
"max_tokens": maxTokens,
}
data, err := json.Marshal(body)
if err != nil {
return ""
}
req, err := http.NewRequest("POST", strings.TrimSuffix(config.Cfg.DeepSeekBaseURL, "/")+"/v1/chat/completions", bytes.NewReader(data))
if err != nil {
return ""
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+config.Cfg.DeepSeekAPIKey)
client := &http.Client{}
resp, err := client.Do(req)
if err != nil {
return ""
}
defer resp.Body.Close()
var result struct {
Choices []struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
} `json:"choices"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return ""
}
if len(result.Choices) > 0 {
return result.Choices[0].Message.Content
}
return ""
}
var dmIntros = map[string]string{
"hardcore": "欢迎来到这场推理盛宴。真相就藏在你面前的线索之中,仔细观察,大胆推理。",
"emotional": "这是一个关于人心的故事。有时候,真相并不重要,重要的是你在这个过程中感受到了什么。",
"欢乐": "欢迎来到这场欢乐的聚会!记住,每个人都在演戏,但真相只有一个!",
"恐怖": "黑暗中有什么在注视着你……准备好了吗?",
}
func cleanJSON(s string) string {
s = strings.TrimSpace(s)
s = strings.TrimPrefix(s, "```json")
s = strings.TrimSuffix(s, "```")
return strings.TrimSpace(s)
}
func DMGenerate(script *scripts.Script, phaseIndex int, releasedClues []string, context string) map[string]interface{} {
phases := script.Phases
var phase *scripts.Phase
if phaseIndex < len(phases) {
phase = &phases[phaseIndex]
} else {
phase = &phases[len(phases)-1]
}
title := script.Title
scriptType := script.Type
typeIntro := dmIntros[scriptType]
cluesStr := "暂无"
if len(releasedClues) > 0 {
cluesStr = strings.Join(releasedClues, ", ")
}
prompt := "你是一个剧本杀主持人(DM),请以主持人的身份发言。\n" +
"当前剧本: " + title + "\n" +
"当前阶段: " + phase.Name + "\n" +
"阶段描述: " + phase.Description + "\n" +
"已发放线索: " + cluesStr + "\n" +
"上下文: " + context + "\n\n" +
"请简短发言(50字以内),推动剧情发展。"
result := ai.chat([]ChatMessage{{Role: "user", Content: prompt}}, 0.7, 512)
if result != "" {
cleaned := cleanJSON(result)
var m map[string]interface{}
if err := json.Unmarshal([]byte(cleaned), &m); err == nil {
return m
}
return map[string]interface{}{"action": "narrate", "content": result}
}
voice := phase.Description
if len(voice) > 80 {
voice = voice[:80]
}
if phaseIndex == 0 && context == "opening" {
bg := script.Background
if len(bg) > 100 {
bg = bg[:100]
}
voice = typeIntro + "\n\n欢迎来到【" + title + "】。" + bg
}
return map[string]interface{}{"action": "narrate", "content": "【" + phase.Name + "】" + voice}
}
func NPCGenerate(role *scripts.Role, background, phaseName string, recentMessages []string, playerMessage string, isPrivate bool, knownClues []string, phaseGoal string, allRoles []*scripts.Role) map[string]interface{} {
personality := role.Personality
if personality == "" {
personality = role.Name
}
cluesStr := "暂无线索"
if len(knownClues) > 0 {
var last []string
if len(knownClues) > 5 {
last = knownClues[len(knownClues)-5:]
} else {
last = knownClues
}
cluesStr = strings.Join(last, " | ")
}
rolesStr := ""
if len(allRoles) > 0 {
var names []string
for _, r := range allRoles {
if r.ID != role.ID {
names = append(names, r.Name)
}
}
if len(names) > 0 {
rolesStr = "其他玩家: " + strings.Join(names, ", ")
}
}
bg := background
if len(bg) > 80 {
bg = bg[:80] + "..."
}
recent := ""
if len(recentMessages) > 0 {
var last []string
if len(recentMessages) > 4 {
last = recentMessages[len(recentMessages)-4:]
} else {
last = recentMessages
}
recent = strings.Join(last, " | ")
}
goalLine := ""
if phaseGoal != "" {
goalLine = "阶段目标: " + phaseGoal
}
privLine := ""
if isPrivate && playerMessage != "" {
privLine = "【私聊】" + playerMessage
} else if playerMessage != "" {
privLine = "【有人对你说】" + playerMessage
}
prompt := "你正在扮演一个剧本杀角色。请严格按照角色设定发言。\n\n" +
"角色信息:\n" +
"- 姓名: " + role.Name + "\n" +
"- 公开身份: " + role.PublicProfile + "\n" +
"- 性格: " + personality + "\n" +
"- 你的目标: " + role.Goal + "\n" +
"- 你的秘密: " + role.SecretProfile + "\n\n" +
"当前背景: " + bg + "\n" +
"当前阶段: " + phaseName + "\n" +
goalLine + "\n" +
rolesStr + "\n\n" +
"你已知道的线索:\n" + cluesStr + "\n\n" +
"最近发言:\n" + recent + "\n\n" +
privLine + "\n\n" +
"【发言规则】\n" +
"1. 以角色身份说人话,简短自然(30字以内)\n" +
"2. 你的秘密绝对不能主动说出来\n" +
"3. 被问到时可以撒谎、回避、转移话题\n" +
"4. 结合已公开的线索来推理和回应\n" +
"5. 如果你知道某些线索的真相(如你就是凶手),可以故意误导他人\n\n" +
"直接输出你的角色发言内容,不要JSON、不要解释。"
result := ai.chat([]ChatMessage{{Role: "user", Content: prompt}}, 0.7, 512)
if result != "" {
cleaned := cleanJSON(result)
var m map[string]interface{}
if err := json.Unmarshal([]byte(cleaned), &m); err == nil {
return m
}
return map[string]interface{}{"action": "speak", "content": result}
}
return map[string]interface{}{"action": "speak", "content": ruleNPCReply(role, playerMessage, isPrivate, knownClues)}
}
func ruleNPCReply(role *scripts.Role, playerMessage string, isPrivate bool, knownClues []string) string {
name := role.Name
clues := knownClues
if playerMessage == "" {
if len(clues) > 0 {
replies := []string{
name + "沉思道:\"这些线索……我觉得需要重新梳理一下。\"",
name + "说:\"我注意到了一些细节,但现在还不方便说。\"",
name + "看了看法医报告:\"时间线和线索对不上,肯定有人撒谎。\"",
}
return replies[randInt(len(replies))]
}
replies := []string{
name + "环顾四周,若有所思。",
name + "清了清嗓子:\"各位,我觉得我们应该整理一下思路。\"",
name + "沉默地看着大家。",
name + "低声说:\"这件事没有那么简单……\"",
}
return replies[randInt(len(replies))]
}
if strings.Contains(playerMessage, "凶手") || strings.Contains(playerMessage, "杀人") || strings.Contains(playerMessage, "你杀") {
denials := []string{
name + "脸色一变:\"你凭什么这么说?证据呢?\"",
name + "冷笑一声:\"如果我是凶手,我还会坐在这里?\"",
name + "摇头:\"我没有理由杀他。\"",
}
return denials[randInt(len(denials))]
}
if strings.Contains(playerMessage, "时间") || strings.Contains(playerMessage, "在哪") || strings.Contains(playerMessage, "案发") {
times := []string{
name + "回忆道:\"我当时在……让我想想。\"",
name + "说:\"那段时间我一个人在房间里。\"",
name + "皱眉:\"我不太确定具体时间,但我确实听到了什么声音。\"",
}
return times[randInt(len(times))]
}
if strings.Contains(playerMessage, "线索") || strings.Contains(playerMessage, "证据") || strings.Contains(playerMessage, "发现") {
clueReplies := []string{
name + "点头:\"这个线索确实值得注意。\"",
name + "沉思:\"但这个线索也可能是在误导我们。\"",
name + "说:\"我也有一个发现,但还不确定是否相关。\"",
}
return clueReplies[randInt(len(clueReplies))]
}
if strings.Contains(playerMessage, "知道") || strings.Contains(playerMessage, "秘密") {
secrets := []string{
name + "回避了你的目光:\"我什么也不知道。\"",
name + "沉默了一会儿:\"每个人都有不想说的秘密,不是吗?\"",
name + "说:\"我只能告诉你,事情不是你看到的那样。\"",
}
return secrets[randInt(len(secrets))]
}
if strings.Contains(playerMessage, "动机") || strings.Contains(playerMessage, "为什么") || strings.Contains(playerMessage, "目的") {
motives := []string{
name + "说:\"每个人都有自己的理由,但有些理由……\"",
name + "耸耸肩:\"动机?也许我们需要先搞清楚发生了什么。\"",
name + "看着你:\"你确定你想知道真正的动机?\"",
}
return motives[randInt(len(motives))]
}
generic := []string{
name + "思考了一下:\"这个嘛,我说不好。\"",
name + "回答:\"我不太确定,但我觉得我们应该继续调查。\"",
name + "说:\"你说得有一定道理,但可能还有其他可能性。\"",
name + "点头:\"有意思,继续说。\"",
}
return generic[randInt(len(generic))]
}