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.
323 lines
10 KiB
Go
323 lines
10 KiB
Go
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))]
|
|
}
|