じ☆ve冰风 发表于 2026-7-25 07:58:09

AI Dialogue

I was programming in Python as usual and using GROQ's API, and I thought, why not create something for RPG Maker MZ too? Since I just finished developing my FOW Minimap, I sat down and wrote some quick code.

I'm giving it away here for free, I'm not developing it further. Take it and do whatever you want – MIT License. I made it as a hobby project, it's not a plugin I'll maintain. Here's what this version offers:









GroqNPC.js – AI-Powered Dynamic NPC Engine for RPG Maker MZ​

Version: 2.2.0
License: MIT – do whatever you want with it
Requirements: RPG Maker MZ, Groq API key (free tier available)

What's Inside​

This plugin connects your RPG Maker MZ game to Groq's fast LLM API (Llama 3.1) to deliver four integrated systems:

Layer - Feature - What It Does
1 - AI Dialogue - Streaming conversations with memory, 10s timeout
2 - Quest Machine - Define quests; all NPCs share state automatically
3 - NPC Knowledge - NPCs know quest status, companions, world state
4 - Enemy Behavior - AI decides tactics, sets MZ switches before battle


Quick Start – Dragon Hunt Example​

1. Define the quest (in an autorun event)​


                Code:       
Plugin Command: Define Quest
Quest ID: dragon_hunt
Title: The Dragon of Ashvale
Objectives: Talk to Mark|Find the lair|Defeat the dragon
NPC Members: Mark,Sera,Old Tom
Quest Switch: 10
Quest Variable: 11



2. Train Mark – the quest giver (in his event)​


                Code:       
Plugin Command: Train NPC Persona
Name: Mark
Personality: You are Mark, a grizzled dragon hunter. You lost your left eye to the Ashvale Dragon 20 years ago. You speak in a gruff, tired voice and constantly warn about danger.
Role: questgiver
Quest ID: dragon_hunt
Guardrails: Never tell the player the dragon is easy to beat
Fallback Message: I ain't lyin' to you about that beast.
Plugin Command: Start AI Dialogue.


3. Train a companion (Sera's event)​

                Code:       
Plugin Command: Train NPC Persona
Name: Sera
Personality: You are Sera, a young eager mage who wants to prove herself. You admire Mark but think he's too cautious. You speak quickly and enthusiastically.
Role: companion
Quest ID: dragon_hunt
Max Tokens: 150

Plugin Command: Start AI Dialogue

4. Enemy behavior – before dragon battle​

                Code:       
Plugin Command: Evaluate Enemy Behavior
Enemy Name: Ashvale Dragon
Enemy Description: Ancient red dragon, wounded but enraged. Scales are cracked on the left side (Mark's old wound). Breathes fire every 3 turns.
Quest ID: dragon_hunt
Behavior Switch Base: 20
Player HP % Variable: 1


Then check switches in your battle event:

[*]Switch 20 ON → Aggressive (full attack)
[*]Switch 21 ON → Defensive (guard/buff)
[*]Switch 22 ON → Flee (escape attempt)
[*]Switch 23 ON → Reinforce (call allies)
[*]Switch 24 ON → Enraged (berserk mode)
5. Advance the quest anywhere​

                Code:       
Plugin Command: Advance Quest Objective
Quest ID: dragon_hunt


All NPCs linked to dragon_hunt will automatically know the new objective.

Text Formatting (AI → MZ Color Codes)​

The AI wraps key words with semantic tags. The plugin converts them to MZ colors:

Tag - Purpose - MZ Color
                Code:       
text

- Dangers, warnings, critical info - Red (2)
                Code:       
text

- Names, places, items, proper nouns - Yellow (14)
                Code:       
text

- Secrets, rare knowledge, confidential- Green (3)
                Code:       
text

- Rumors, uncertain info, vague hints - Gray (8)



Example AI response:

                Code:       
DIALOGUE: The Dragon of Ashvale sleeps near the
eastern caves. It is extremely dangerous
– its fire can melt steel in seconds.

PROMPT: Ask about the weak spot?


What the player sees:Colored text automatically (yellow for locations, red for warnings, etc.)


Plugin Parameters (Configure in Plugin Manager)​

Parameter - Default - Description
                Code:       
Groq API Key

- (Empty) - Get from console.groq.com/keys
                Code:       
AI Model

- llama-3.1-8b-instant - Visit HEREfor more
                Code:       
Stream Timeout

- 10 seconds - Hard cutoff. Partial text shown if triggered
                Code:       
Chat Memory

- 4 exchanges - How many back-and-forth messages per NPC to remember
                Code:       
Default Input Prompt

- What do you say? - Fallback when AI doesn't generate a prompt
                Code:       
Max Player Input Length

- 100 characters - Limits what player can type
                Code:       
Global System Prompt Suffix

- (Empty) - Appended to ALL NPC prompts. Example: "Keep responses under 3 sentences. Never break character."
                Code:       
Timeout Fallback Message

- Hmm... I need a moment to think - Shown when API times out
                Code:       
Debug Mode

- false - Logs full API payloads to F8 console

Complete Plugin Command Reference​

Quest Management​

Command - Arguments - Description
                Code:       
defineQuest

- questId, title, description, objectives (pipe-separated), npcMembers (comma), questSwitch, questVariable - reate/update a quest
                Code:       
advanceObjective

- questId - Mark current objective done, move to next
                Code:       
completeQuest

- questId - Mark quest as fully completed
                Code:       
failQuest

- questId - Mark quest as failed

NPC Persona​

Command - Arguments - Description
                Code:       
trainPersona

- name, personality, role (none/questgiver/companion/merchant/informant), questId, guardrails, fallbackMessage, maxTokens - Define NPC personality. Call BEFORE startDialogue
                Code:       
startDialogue

- (none) - Begin AI conversation with this NPC
                Code:       
clearMemory

- (none) - Wipe this NPC's conversation history
                Code:       
clearAllMemory

- (none) - Wipe ALL NPC conversation histories
Enemy Behavior​

Command - Arguments - Description
                Code:       
evaluateEnemyBehavior

- enemyName, enemyDescription, questId, behaviorSwitchBase, playerHpPercent - AI decides behavior, sets MZ switches
Utilities​

Command - Arguments - Description
                Code:       
testApiKey

- (none) - Verify API key works. Shows result in message window
                Code:       
setContextVar

- label, variableId or switchId - Expose game state to ALL AI calls globally

️ Notetags (Alternative to Train Persona)​

Place these in the event's Note field:

                Code:       
<AIPersona: You are a paranoid old hermit who hoards mushrooms and mistrusts strangers.>
<AIRole: informant>
<AIQuestID: dragon_hunt>
<AIGuardrails: Never talk about the royal family. Never reveal the dragon's location directly.>
<AIFallback: I... I cannot speak of such things... The walls have ears.>
<AIMaxTokens: 150>

Note: Train Persona plugin commands override notetags.

NPC Memory & Knowledge System​

What NPCs Know Automatically​


[*]Quest status – all quests linked to them via questId
[*]Current objective – which step the player is on
[*]Quest members – names of all NPCs in the same quest
[*]World state – any variables/switches exposed via setContextVar
[*]Conversation history – last N exchanges (configurable)
Example: Exposing World State​


                Code:       
Plugin Command: Set Context Variable
Label: Player defeated the dragon
Switch ID: 25

Now EVERY NPC sees this in their system prompt:

                Code:       

Player defeated the dragon: true

They will react accordingly. A merchant might say: "I heard you slew the dragon! Take this discount."

Memory Management​


[*]Memory is saved in the save file
[*]Each NPC has independent memory (by map ID + event ID)
[*]Use Clear NPC Memory to reset an NPC
[*]Use Clear All NPC Memory to reset everyone
Enemy Behavior Deep Dive​

Behavior Switch Mapping​

When you call evaluateEnemyBehavior with Behavior Switch Base: 20:
Switch - Behavior - AI Decides This When

20 - Aggressive - Player HP is low, enemy has advantage, quest near start
21 - Defensive - Enemy is wounded, protecting something, buying time
22 - Flee - Enemy is outmatched, mission objective complete, low HP
23 - Reinforce - Enemy is alone vs party, quest climax approaching
24 - Enraged - Quest-specific trigger (e.g., dragon at 30% HP, companion died)

Only ONE switch is ON at a time. Use conditional branches to check them.

Enemy Evaluation Example​

The AI receives this system prompt internally:

                Code:       
You are an AI Game Master deciding enemy combat behavior.
Enemy: Ashvale Dragon
Description: Ancient red dragon, wounded but enraged.

Status: ACTIVE
Objectives:
DONE: Talk to Mark
CURRENT: Find the lair
PENDING: Defeat the dragon
Player current HP: 45%

Player has the Dragon Slayer sword: true



Choose ONE behavior: aggressive / defensive / flee / reinforce / enraged
The AI responds with a single word, and the plugin sets the corresponding switch.

Save File Compatibility​

The plugin saves the following data automatically:

[*]_npcMemory – conversation histories
[*]_trainedPersonas – all trained NPC personalities
[*]_quests – quest states (status, current objective)
[*]_ctxVars – exposed context variables
No special handling needed. Save and load normally.


Limitations & Warnings​

Issue - Detail
API key visibility - Client-side calls mean the API key is visible in player's browser. Obfuscation possible but not implemented.
Internet required - Game won't work offline for AI features. Regular MZ gameplay unaffected.
API costs - You pay for usage. Groq is very cheap (~$0.0002 per dialogue) but not free.
Steam policy - Must declare AI-generated content in your store listing.

No ongoing development - I made this for fun. It works as-is. I won't fix bugs or add features.



Troubleshooting​

"API key is empty" error​


[*]Check Plugin Manager → GroqNPC → Groq API Key
[*]Key should start with gsk_
[*]No spaces before or after
Timeout errors​


[*]Increase Stream Timeout parameter (10-15 seconds recommended)
[*]Switch to llama-3.1-8b-instant model (faster)
No response or weird formatting​


[*]Enable Debug Mode and check F8 console
[*]Verify your Groq account has credits
[*]Test with testApiKey plugin command
NPC doesn't remember quest​


[*]Ensure Quest ID matches exactly (case-sensitive)
[*]Call trainPersona BEFORE startDialogue
[*]Check that the quest was defined (run defineQuest first)
License & Credits​

MIT License – do literally anything you want.


[*]No credit required (but appreciated)
[*]No warranty
[*]No support
Built with:


[*]Groq API – fast LLM inference
[*]Groq Models
[*]GitHub Project
[*]RPG Maker MZ Plugin by Rpx in collaboration with GDSchool
Final Words​

That's it. Take it, break it, fix it, improve it, sell games with it.

If you make something cool, I'd love to see it. If you don't, that's fine too.

Enjoy!
↓FULL CODE ↓
Spoiler                JavaScript:       
//=============================================================================
//=============================================================================
// GroqNPC.js - Groq-Powered Dynamic NPC Engine for RPG Maker MZ
// Version: 2.2.0
// Changelog:
//   2.0.0 - Quest State Machine, NPC Knowledge Sharing, Enemy Behavior AI,
//            dynamic input prompt, structured AI response (DIALOGUE/PROMPT)
//   1.2.0 - Hybrid text formatting (semantic tags -> MZ color codes)
//   1.1.0 - Streaming API, 10s timeout, Train NPC Persona command
//   1.0.0 - Initial release
//=============================================================================
// LEGAL DISCLAIMER:
// Client-side calls to Groq API. You accept Groq ToS (https://groq.com/terms).
// Declare AI-generated content in your store listing (Steam policy).
// Author not liable for API costs or AI-generated content.
//=============================================================================
/*:
* @target MZ
* @plugindesc v2.2 | Groq AI Game Master - Dynamic NPCs, Quest Machine, Enemy Behavior
* @author Rpx
* @url https://gdschool.education/
*
* @help
* ============================================================================
* GROQ NPC ENGINE v2.0 - OVERVIEW
* ============================================================================
*
* Four systems work together:
*   LAYER 1 - AI Dialogue       Streaming NPC conversation, max 10s response
*   LAYER 2 - Quest Machine   Define quests; all tagged NPCs share state
*   LAYER 3 - NPC Knowledge   NPCs know quest status, companions, world state
*   LAYER 4 - Enemy Behavior    AI decides enemy behavior, sets MZ Switches
*
* ============================================================================
* QUICK START - DRAGON HUNTER QUEST EXAMPLE
* ============================================================================
*
* 1. In a map autorun or quest-giver event:
*    Define Quest
*      ID: dragon_hunt | Title: The Dragon of Ashvale
*      Objectives: Talk to Mark|Find the lair|Defeat the dragon
*      Members: Mark,Sera,Old Tom | Quest Switch: 10
*
* 2. Mark's event (quest giver):
*    Train NPC Persona
*      Name: Mark | Role: questgiver | Quest ID: dragon_hunt
*      Personality: You are Mark, veteran dragon hunter...
*    Start AI Dialogue
*
* 3. Companion events (Sera, Old Tom):
*    Train NPC Persona
*      Role: companion | Quest ID: dragon_hunt
*    Start AI Dialogue
*
* 4. Dragon enemy event (before battle):
*    Evaluate Enemy Behavior
*      Enemy: Ashvale Dragon | Quest ID: dragon_hunt
*      Behavior Switch Base: 20
*    Switch 20 ON -> aggressive pattern
*    Switch 21 ON -> defensive pattern
*    Switch 22 ON -> flee
*    Switch 23 ON -> call reinforcements
*    Switch 24 ON -> enraged
*
* 5. Advance quest anywhere:
*    Advance Quest Objective | Quest ID: dragon_hunt
*
* ============================================================================
* NOTETAGS (Event Note field - overridden by Train NPC Persona command)
* ============================================================================
*
* <AIPersona: >
* <AIGuardrails: >
* <AIFallback: >
* <AIMaxTokens: >
* <AIQuestID: >
* <AIRole: questgiver|companion|merchant|informant>
*
* ============================================================================
* TEXT FORMATTING TAGS
* ============================================================================
*
* AI wraps key words - plugin converts to MZ color codes:
*   ...-> Red    (dangers, warnings)
*   ...-> Yellow (names, places, items)
*   ...      -> Green(secrets, rare info)
*   ...            -> Gray   (rumors, uncertain)
*
* ============================================================================
* ENEMY BEHAVIOR SWITCHES (Base ID set in Evaluate Enemy Behavior command)
* ============================================================================
*
*   Base+0-> Aggressive(full attack)
*   Base+1-> Defensive   (guard/buff)
*   Base+2-> Flee      (escape attempt)
*   Base+3-> Reinforce   (call allies)
*   Base+4-> Enraged   (quest-triggered berserk)
*
* Only ONE switch is ON at a time. Use Conditional Branch on each.
*
* ============================================================================
*
* @param apiKey
* @text Groq API Key
* @type string
* @desc Get one at https://console.groq.com/keys
* @default
*
* @param model
* @text AI Model
* @type combo
* @option llama-3.1-8b-instant
* @option llama-3.3-70b-versatile
* @desc llama-3.1-8b-instant = fastest + cheapest. Recommended.
* @default llama-3.1-8b-instant
*
* @param streamTimeout
* @text Stream Timeout (seconds)
* @type number
* @min 3
* @max 30
* @desc Hard cutoff. Partial text shown if triggered.
* @default 10
*
* @param maxMemory
* @text Chat Memory (exchanges per NPC)
* @type number
* @min 1
* @max 20
* @default 4
*
* @param defaultInputPrompt
* @text Default Input Prompt
* @type string
* @desc Fallback when AI does not generate a contextual prompt.
* @default What do you say?
*
* @param maxInputLength
* @text Max Player Input Length
* @type number
* @min 10
* @max 200
* @default 100
*
* @param globalSystemSuffix
* @text Global System Prompt Suffix
* @type multiline_string
* @desc Appended to ALL NPC system prompts.
* @default Keep responses under 3 sentences. Stay in character. Never mention being an AI.
*
* @param timeoutMessage
* @text Timeout Fallback Message
* @type string
* @default Hmm... I need a moment to think.
*
* @param enableFormatting
* @text Enable AI Text Formatting
* @type boolean
* @default true
*
* @param colorImportant
* @text Color:
* @type number
* @min -1
* @max 31
* @desc MZ color index. 2=red. -1=disabled.
* @default 2
*
* @param colorHighlight
* @text Color:
* @type number
* @min -1
* @max 31
* @desc MZ color index. 14=yellow. -1=disabled.
* @default 14
*
* @param colorSecret
* @text Color:
* @type number
* @min -1
* @max 31
* @desc MZ color index. 3=green. -1=disabled.
* @default 3
*
* @param colorWeak
* @text Color:
* @type number
* @min -1
* @max 31
* @desc MZ color index. 8=gray. -1=disabled.
* @default 8
*
* @param debugMode
* @text Debug Mode
* @type boolean
* @desc Log full payloads to F8 console.
* @default false
*
* @command testApiKey
* @text Test API Connection
* @desc Run this in any event to verify your API key works. Shows result in a message window.
*
* @command defineQuest
* @text Define Quest
* @desc Create/update a quest. All linked NPCs share this quest state.
*
* @arg questId
* @text Quest ID
* @type string
* @desc Unique ID, no spaces. e.g. "dragon_hunt"
* @default my_quest
*
* @arg title
* @text Quest Title
* @type string
* @default The Quest
*
* @arg description
* @text Quest Description
* @type multiline_string
* @default A quest to be completed.
*
* @arg objectives
* @text Objectives (pipe-separated)
* @type string
* @desc e.g. "Find Mark|Reach the cave|Defeat the dragon"
* @default Objective 1|Objective 2|Objective 3
*
* @arg npcMembers
* @text NPC Members (comma-separated)
* @type string
* @desc Names of all NPCs involved. They all share quest knowledge.
* @default NPC1,NPC2
*
* @arg questSwitch
* @text Quest Active Switch ID
* @type switch
* @desc Set ON when quest is active. 0 = none.
* @default 0
*
* @arg questVariable
* @text Quest Progress Variable ID
* @type variable
* @desc Stores current objective index (0-based). 0 = none.
* @default 0
*
* @command advanceObjective
* @text Advance Quest Objective
* @desc Mark current objective done, move to next.
*
* @arg questId
* @text Quest ID
* @type string
* @default my_quest
*
* @command completeQuest
* @text Complete Quest
* @desc Mark quest as fully completed.
*
* @arg questId
* @text Quest ID
* @type string
* @default my_quest
*
* @command failQuest
* @text Fail Quest
* @desc Mark quest as failed.
*
* @arg questId
* @text Quest ID
* @type string
* @default my_quest
*
* @command trainPersona
* @text Train NPC Persona
* @desc Define NPC personality and quest link. Call BEFORE Start AI Dialogue.
*
* @arg name
* @text NPC Name
* @type string
* @default Villager
*
* @arg personality
* @text Personality & Background
* @type multiline_string
* @desc Role, traits, knowledge, speech style.
* @default You are a friendly villager in a medieval fantasy town.
*
* @arg role
* @text Quest Role
* @type select
* @option none
* @option questgiver
* @option companion
* @option merchant
* @option informant
* @default none
*
* @arg questId
* @text Quest ID (optional)
* @type string
* @desc Link this NPC to a quest. Leave empty for standalone.
* @default
*
* @arg guardrails
* @text Forbidden Topics
* @type multiline_string
* @default
*
* @arg fallbackMessage
* @text Guardrail Fallback
* @type string
* @default I cannot speak of such things.
*
* @arg maxTokens
* @text Max Response Tokens
* @type number
* @min 30
* @max 400
* @default 120
*
* @command startDialogue
* @text Start AI Dialogue
* @desc Begin AI conversation with this NPC.
*
* @command evaluateEnemyBehavior
* @text Evaluate Enemy Behavior
* @desc AI decides enemy behavior and sets MZ Switches.
*
* @arg enemyName
* @text Enemy Name
* @type string
* @default Enemy
*
* @arg enemyDescription
* @text Enemy Description
* @type multiline_string
* @desc Personality, lore, combat context.
* @default A fierce monster guarding its territory.
*
* @arg questId
* @text Quest ID (optional)
* @type string
* @default
*
* @arg behaviorSwitchBase
* @text Behavior Switch Base ID
* @type switch
* @desc Base+0=Aggressive, Base+1=Defensive, Base+2=Flee, Base+3=Reinforce, Base+4=Enraged
* @default 1
*
* @arg playerHpPercent
* @text Player HP % Variable
* @type variable
* @desc Variable with player HP 0-100. 0 = not used.
* @default 0
*
* @command setContextVar
* @text Set Context Variable
* @desc Expose a game variable or switch to all AI calls globally.
*
* @arg label
* @text Label
* @type string
* @desc Human-readable name. e.g. "Player defeated the dragon"
*
* @arg variableId
* @text Variable ID
* @type variable
*
* @arg switchId
* @text Switch ID
* @type switch
*
* @command clearMemory
* @text Clear NPC Memory
* @desc Wipe this NPC's conversation history.
*
* @command clearAllMemory
* @text Clear All NPC Memory
* @desc Wipe ALL NPC conversation histories.
*/
(() => {
    "use strict";
    const PLUGIN_NAME = "GroqNPC";
    const LOG_PREFIX= "";
    const API_URL   = "https://api.groq.com/openai/v1/chat/completions";
    //=========================================================================
    // Config
    //=========================================================================
    const params = PluginManager.parameters(PLUGIN_NAME);
    // Parsing robusto: trim + gestione undefined/null
    const _rawKey = params["apiKey"] !== undefined ? String(params["apiKey"]).trim() : "";
    const Config = {
      apiKey      : _rawKey,
      model         : String(params.model            || "llama-3.1-8b-instant"),
      streamTimeout : Number(params.streamTimeout      || 10) * 1000,
      maxMemory   : Number(params.maxMemory          || 4),
      defaultPrompt : String(params.defaultInputPrompt || "What do you say?"),
      maxInputLen   : Number(params.maxInputLength   || 100),
      globalSuffix: String(params.globalSystemSuffix || ""),
      timeoutMsg    : String(params.timeoutMessage   || "Hmm... I need a moment to think."),
      enableFmt   : params.enableFormatting          !== "false",
      colorImportant: Number(params.colorImportant   ?? 2),
      colorHighlight: Number(params.colorHighlight   ?? 14),
      colorSecret   : Number(params.colorSecret      ?? 3),
      colorWeak   : Number(params.colorWeak          ?? 8),
      debugMode   : params.debugMode               === "true",
    };
    const log= (...a) => { if (Config.debugMode) console.log(LOG_PREFIX, ...a); };
    const warn = (...a) => console.warn(LOG_PREFIX,...a);
    const lerr = (...a) => console.error(LOG_PREFIX, ...a);
    //=========================================================================
    // LAYER 2 - Quest State Machine
    //=========================================================================
    const _quests = {};
    function getQuest(id) { return _quests || null; }
    function buildQuestContext(questId) {
      const q = getQuest(questId);
      if (!q) return "";
      const objectives = q.objectives.map((obj, i) => {
            const done = i < q.currentObjective;
            const cur= i === q.currentObjective && q.status === "active";
            return `${done ? "DONE" : cur ? "CURRENT" : "PENDING"}: ${obj}`;
      }).join("\n");
      return `\n
Status: ${q.status.toUpperCase()}
Description: ${q.description}
Objectives:\n${objectives}
Active objective: ${q.objectives || "All complete"}
Quest members: ${q.npcMembers.join(", ")}`;
    }
    function advanceQuestObjective(questId) {
      const q = getQuest(questId);
      if (!q || q.status !== "active") return;
      q.currentObjective = Math.min(q.currentObjective + 1, q.objectives.length - 1);
      if (q.questVariable > 0) $gameVariables.setValue(q.questVariable, q.currentObjective);
      log("Quest advanced:", questId, "-> objective", q.currentObjective);
    }
    //=========================================================================
    // LAYER 3 - NPC Memory & Knowledge
    //=========================================================================
    const _npcMemory       = {};
    const _trainedPersonas = {};
    const memKey   = (m, e) => `${m}_${e}`;
    const getMem   = (m, e) => { const k = memKey(m,e); if (!_npcMemory) _npcMemory=[]; return _npcMemory; };
    const clearMem = (m, e) => { _npcMemory = []; };
    function pushMem(mapId, evId, role, content) {
      const mem = getMem(mapId, evId);
      mem.push({ role, content: stripTags(content) });
      const max = Config.maxMemory * 2;
      while (mem.length > max) mem.shift();
    }
    //=========================================================================
    // Context Variables
    //=========================================================================
    const _ctxVars = [];
    function buildCtxString() {
      const lines = _ctxVars.map(cv => {
            if (cv.variableId > 0) return `${cv.label}: ${$gameVariables.value(cv.variableId)}`;
            if (cv.switchId   > 0) return `${cv.label}: ${$gameSwitches.value(cv.switchId) ? "true" : "false"}`;
            return null;
      }).filter(Boolean);
      return lines.length ? "\n\n" + lines.join("\n") : "";
    }
    //=========================================================================
    // Notetag Parser
    //=========================================================================
    function parseNotetags(event) {
      const note = event.event().note || "";
      const get= (tag, def) => { const m = note.match(new RegExp(`<${tag}:\\s*([^>]+)>`, "i")); return m ? m.trim() : def; };
      return {
            name      : get("AIName",      "NPC"),
            persona   : get("AIPersona",   "You are a villager in a fantasy RPG."),
            guardrails: get("AIGuardrails",""),
            fallback: get("AIFallback","I cannot speak of such things."),
            maxTokens : Number(get("AIMaxTokens", "120")),
            questId   : get("AIQuestID",   ""),
            role      : get("AIRole",      "none"),
      };
    }
    //=========================================================================
    // System Prompt Builder
    //=========================================================================
    const ROLE_DESC = {
      questgiver: "You are the quest giver. Explain objectives, urgency, and reward clearly.",
      companion : "You are a companion in this quest. Share tips, morale, and quest details.",
      merchant: "You are a merchant. Sell useful items and share quest-related rumors.",
      informant : "You are an informant. Share world information carefully and selectively.",
    };
    const FORMAT_INSTRUCTIONS = `

... = dangers, warnings, critical info
... = item names, locations, proper nouns
...       = secrets, rare knowledge, confidential
...         = rumors, uncertain info, vague hints`;
    const RESPONSE_FORMAT = `

Reply using EXACTLY this structure, no exceptions:
DIALOGUE: <your in-character reply, 1-3 sentences>
PROMPT: <short question hint for player input, max 6 words>
Example:
DIALOGUE: The Dragon of Ashvale sleeps near the eastern caves. It is extremely dangerous.
PROMPT: Ask about the reward?`;
    function buildSysPrompt(tags) {
      let p = `You are ${tags.name}. ${tags.persona}`;
      if (ROLE_DESC) p += `\nRole: ${ROLE_DESC}`;
      if (tags.questId) {
            const qCtx = buildQuestContext(tags.questId);
            if (qCtx) p += qCtx;
      }
      if (tags.guardrails) {
            p += `\n\n${tags.guardrails}`;
            p += `\nIf forbidden topic raised, say ONLY: "${tags.fallback}"`;
      }
      const ctx = buildCtxString();
      if (ctx) p += ctx;
      if (Config.enableFmt) p += FORMAT_INSTRUCTIONS;
      p += RESPONSE_FORMAT;
      if (Config.globalSuffix) p += "\n" + Config.globalSuffix;
      log("System prompt built for:", tags.name, "| quest:", tags.questId);
      log("Full prompt:", p);
      return p;
    }
    //=========================================================================
    // LAYER 4 - Enemy Behavior Evaluator
    //=========================================================================
    const ENEMY_BEHAVIORS = ["aggressive", "defensive", "flee", "reinforce", "enraged"];
    async function evaluateEnemyBehavior(enemyName, enemyDesc, questId, playerHpPct) {
      if (!Config.apiKey) {
            lerr("API key empty in evaluateEnemyBehavior. Raw:", params["apiKey"]);
            throw new Error("Groq API key not set. Check Plugin Manager.");
      }
      let sys = `You are an AI Game Master deciding enemy combat behavior.
Enemy: ${enemyName}
${enemyDesc ? "Description: " + enemyDesc : ""}`;
      if (questId) {
            const qCtx = buildQuestContext(questId);
            if (qCtx) sys += qCtx;
      }
      if (playerHpPct > 0) sys += `\nPlayer current HP: ${playerHpPct}%`;
      sys += buildCtxString();
      sys += `

Based on all context, choose ONE behavior for this enemy:
- aggressive: full attack, no mercy, player is manageable
- defensive:protect self, use guards/buffs, buying time
- flee:       escape, enemy is outmatched or mission complete
- reinforce:call for allies, enemy needs backup now
- enraged:    quest-triggered power surge, goes berserk (use when quest reaches climax)
Respond with ONLY one word. No punctuation, no explanation.`;
      const payload = {
            model   : Config.model,
            max_tokens: 5,
            stream    : false,
            messages: [
                { role: "system", content: sys },
                { role: "user",   content: "Choose behavior." }
            ],
      };
      log("Enemy eval payload:", JSON.stringify(payload));
      const res = await fetch(API_URL, {
            method : "POST",
            headers: { "Content-Type": "application/json", "Authorization": `Bearer ${Config.apiKey}` },
            body   : JSON.stringify(payload),
      });
      if (!res.ok) throw new Error(`Groq API ${res.status}`);
      const data   = await res.json();
      const behavior = (data.choices.message.content || "").trim().toLowerCase().split(/[\s\n]/);
      const idx      = ENEMY_BEHAVIORS.indexOf(behavior);
      log(`Enemy "${enemyName}" behavior: "${behavior}" -> index ${idx >= 0 ? idx : 0}`);
      return idx >= 0 ? idx : 0;
    }
    //=========================================================================
    // Text Formatting
    //=========================================================================
    function stripTags(text) {
      return text
            .replace(/\[(important|highlight|secret|weak)\]([\s\S]*?)\[\/\1\]/gi, "$2")
            .replace(/\[(\/?)(?:important|highlight|secret|weak)\]/gi, "");
    }
    function convertTags(text) {
      if (!Config.enableFmt) return stripTags(text);
      const map = {
            important: Config.colorImportant,
            highlight: Config.colorHighlight,
            secret   : Config.colorSecret,
            weak   : Config.colorWeak,
      };
      let r = text;
      for (const of Object.entries(map)) {
            if (idx < 0) {
                r = r.replace(new RegExp(`\\[${tag}\\]([\\s\\S]*?)\\[\\/${tag}\\]`, "gi"), "$1");
            } else {
                r = r.replace(
                  new RegExp(`\\[${tag}\\]([\\s\\S]*?)\\[\\/${tag}\\]`, "gi"),
                  `\\C[${idx}]$1\\C`
                );
            }
      }
      return r.replace(/\[(\/?)(?:important|highlight|secret|weak)\]/gi, "");
    }
    //=========================================================================
    // Structured Response Parser - DIALOGUE: / PROMPT:
    //=========================================================================
    function parseResponse(raw) {
      const dm = raw.match(/DIALOGUE:\s*([\s\S]*?)(?=\nPROMPT:|$)/i);
      const pm = raw.match(/PROMPT:\s*(.+)/i);
      return {
            dialogue: (dm ? dm : raw).trim(),
            prompt: (pm ? pm : Config.defaultPrompt).trim(),
      };
    }
    //=========================================================================
    // Groq Streaming
    //=========================================================================
    async function callGroqStream(sysPrompt, messages, maxTokens) {
      if (!Config.apiKey) {
            const msg = `Groq API key is empty. Check Plugin Manager > GroqNPC > Groq API Key. Raw value: "${params["apiKey"]}"`;
            lerr(msg);
            throw new Error(msg);
      }
      const controller = new AbortController();
      const timeoutId= setTimeout(() => controller.abort(), Config.streamTimeout);
      const payload = {
            model   : Config.model,
            max_tokens: maxTokens,
            stream    : true,
            messages: [{ role: "system", content: sysPrompt }, ...messages],
      };
      let accumulated = "";
      try {
            const res = await fetch(API_URL, {
                method : "POST",
                headers: { "Content-Type": "application/json", "Authorization": `Bearer ${Config.apiKey}` },
                body   : JSON.stringify(payload),
                signal : controller.signal,
            });
            if (!res.ok) {
                const ed = await res.json().catch(() => ({}));
                throw new Error(`Groq API ${res.status}: ${ed?.error?.message || res.statusText}`);
            }
            const reader= res.body.getReader();
            const decoder = new TextDecoder("utf-8");
            outer: while (true) {
                let chunk;
                try   { chunk = await reader.read(); }
                catch (_) { log("Stream cut by timeout. Got:", accumulated); break outer; }
                if (chunk.done) break;
                for (const line of decoder.decode(chunk.value, { stream: true }).split("\n")) {
                  if (!line.startsWith("data: ")) continue;
                  const data = line.slice(6).trim();
                  if (data === "") break outer;
                  try {
                        const delta = JSON.parse(data)?.choices?.?.delta?.content;
                        if (delta) accumulated += delta;
                  } catch (_) {}
                }
            }
      } catch (e) {
            if (e.name !== "AbortError") { clearTimeout(timeoutId); throw e; }
            log("Fetch aborted (timeout).");
      }
      clearTimeout(timeoutId);
      return accumulated.trim() || Config.timeoutMsg;
    }
    //=========================================================================
    // Window_NpcInput - dynamic prompt text
    //=========================================================================
    class Window_NpcInput extends Window_Base {
      initialize(rect) {
            super.initialize(rect);
            this._text      = "";
            this._active      = false;
            this._prompt      = Config.defaultPrompt;
            this._cursorTimer = 0;
            this._cursorVis   = true;
            this._onConfirm   = null;
            this._onCancel    = null;
            this._boundKD   = this._onKeyDown.bind(this);
            this.openness   = 0;
      }
      activate(prompt, onConfirm, onCancel) {
            this._text      = "";
            this._prompt    = prompt || Config.defaultPrompt;
            this._active    = true;
            this._onConfirm = onConfirm;
            this._onCancel= onCancel;
            this.open();
            this.refresh();
            document.addEventListener("keydown", this._boundKD, true);
      }
      deactivate() {
            this._active = false;
            document.removeEventListener("keydown", this._boundKD, true);
            this.close();
      }
      _onKeyDown(e) {
            if (!this._active) return;
            if (e.key === "Enter") {
                e.preventDefault(); e.stopPropagation();
                const t = this._text.trim();
                if (t) { this.deactivate(); this._onConfirm(t); }
                return;
            }
            if (e.key === "Escape") {
                e.preventDefault(); e.stopPropagation();
                this.deactivate(); this._onCancel();
                return;
            }
            if (e.key === "Backspace") {
                e.preventDefault();
                this._text = this._text.slice(0, -1);
                this.refresh();
                return;
            }
            if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
                if (this._text.length < Config.maxInputLen) { this._text += e.key; this.refresh(); }
            }
      }
      update() {
            super.update();
            if (this._active && ++this._cursorTimer >= 30) {
                this._cursorTimer = 0;
                this._cursorVis   = !this._cursorVis;
                this.refresh();
            }
      }
      refresh() {
            this.contents.clear();
            const lh = this.lineHeight();
            this.changeTextColor(ColorManager.systemColor());
            this.drawText(this._prompt, 0, 0, this.innerWidth, "left");
            this.resetTextColor();
            const cursor = this._cursorVis && this._active ? "|" : " ";
            this.drawText(this._text + cursor, 0, lh, this.innerWidth - 90, "left");
            this.changeTextColor(ColorManager.textColor(8));
            this.drawText(`${this._text.length}/${Config.maxInputLen}`, 0, lh, this.innerWidth, "right");
            this.resetTextColor();
            this.changeTextColor(ColorManager.textColor(7));
            this.drawText(" Send    Leave", 0, lh * 2, this.innerWidth, "right");
            this.resetTextColor();
      }
    }
    //=========================================================================
    // Scene_Map - inject input window
    //=========================================================================
    const _origCreate = Scene_Map.prototype.createAllWindows;
    Scene_Map.prototype.createAllWindows = function () {
      _origCreate.call(this);
      const ww   = Graphics.boxWidth * 0.82;
      const wh   = this.calcWindowHeight(3, false);
      const rect = new Rectangle((Graphics.boxWidth - ww) / 2, Graphics.boxHeight - wh - 8, ww, wh);
      this._npcInputWindow = new Window_NpcInput(rect);
      this.addWindow(this._npcInputWindow);
    };
    const getInputWin = () => SceneManager._scene?._npcInputWindow || null;
    //=========================================================================
    // Plugin Commands
    //=========================================================================
    // --- Test API Connection (debug) ---
    PluginManager.registerCommand(PLUGIN_NAME, "testApiKey", function () {
      const interp = this;
      interp._waitCount = 9999;
      (async () => {
            const key = Config.apiKey;
            if (!key) {
                $gameMessage.clear();
                $gameMessage.add(" API Key is EMPTY.");
                $gameMessage.add(`Raw param value: "${params["apiKey"]}"`);
                interp._waitCount = 0;
                return;
            }
            try {
                const res = await fetch(API_URL, {
                  method : "POST",
                  headers: { "Content-Type": "application/json", "Authorization": `Bearer ${key}` },
                  body   : JSON.stringify({
                        model: Config.model, max_tokens: 5, stream: false,
                        messages: [{ role: "user", content: "Say OK" }]
                  }),
                });
                const data = await res.json();
                $gameMessage.clear();
                if (res.ok) {
                  $gameMessage.add(" API OK! Key works.");
                  $gameMessage.add(`Model: ${Config.model}`);
                } else {
                  $gameMessage.add(` API ERROR ${res.status}`);
                  $gameMessage.add(data?.error?.message || "Unknown error");
                }
            } catch (e) {
                $gameMessage.clear();
                $gameMessage.add(" Network error:");
                $gameMessage.add(e.message);
            }
            interp._waitCount = 0;
      })();
    });
    PluginManager.registerCommand(PLUGIN_NAME, "defineQuest", function (args) {
      const id = String(args.questId || "").trim();
      if (!id) { warn("defineQuest: questId is empty."); return; }
      const switchId = Number(args.questSwitch   || 0);
      const varId    = Number(args.questVariable || 0);
      _quests = {
            id,
            title         : String(args.title       || id),
            description   : String(args.description || ""),
            objectives      : String(args.objectives|| "").split("|").map(s => s.trim()).filter(Boolean),
            currentObjective: 0,
            status          : "active",
            npcMembers      : String(args.npcMembers|| "").split(",").map(s => s.trim()).filter(Boolean),
            questSwitch   : switchId,
            questVariable   : varId,
      };
      if (switchId > 0) $gameSwitches.setValue(switchId, true);
      if (varId    > 0) $gameVariables.setValue(varId, 0);
      log("Quest defined:", id, _quests);
    });
    PluginManager.registerCommand(PLUGIN_NAME, "advanceObjective", function (args) {
      advanceQuestObjective(String(args.questId || "").trim());
    });
    PluginManager.registerCommand(PLUGIN_NAME, "completeQuest", function (args) {
      const q = getQuest(String(args.questId || "").trim());
      if (!q) { warn("completeQuest: quest not found"); return; }
      q.status = "completed";
      q.currentObjective = q.objectives.length - 1;
      if (q.questSwitch > 0) $gameSwitches.setValue(q.questSwitch, false);
      log("Quest completed:", q.id);
    });
    PluginManager.registerCommand(PLUGIN_NAME, "failQuest", function (args) {
      const q = getQuest(String(args.questId || "").trim());
      if (!q) { warn("failQuest: quest not found"); return; }
      q.status = "failed";
      if (q.questSwitch > 0) $gameSwitches.setValue(q.questSwitch, false);
      log("Quest failed:", q.id);
    });
    PluginManager.registerCommand(PLUGIN_NAME, "trainPersona", function (args) {
      const k = memKey($gameMap.mapId(), this._eventId);
      _trainedPersonas = {
            name      : String(args.name         || "NPC"),
            persona   : String(args.personality    || "You are a villager."),
            role      : String(args.role         || "none"),
            questId   : String(args.questId      || ""),
            guardrails: String(args.guardrails      || ""),
            fallback: String(args.fallbackMessage || "I cannot speak of such things."),
            maxTokens : Number(args.maxTokens       || 120),
      };
      log("Persona trained:", _trainedPersonas.name, "| role:", _trainedPersonas.role, "| quest:", _trainedPersonas.questId);
    });
    PluginManager.registerCommand(PLUGIN_NAME, "startDialogue", function () {
      const interp= this;
      const event   = $gameMap.event(interp._eventId);
      const mapId   = $gameMap.mapId();
      const eventId = interp._eventId;
      if (!event) { warn("startDialogue: event not found. ID:", eventId); return; }
      const k    = memKey(mapId, eventId);
      const tags = _trainedPersonas || parseNotetags(event);
      const sys= buildSysPrompt(tags);
      interp._waitCount = 999999;
      (async () => {
            let active      = true;
            let currentPrompt = Config.defaultPrompt;
            while (active) {
                const input = await getPlayerInput(currentPrompt);
                if (input === null) break;
                pushMem(mapId, eventId, "user", input);
                let raw = "";
                try {
                  // +40 tokens headroom for DIALOGUE:/PROMPT: structure overhead
                  raw = await callGroqStream(sys, getMem(mapId, eventId), tags.maxTokens + 40);
                } catch (e) {
                  lerr("API error:", e.message);
                  raw = `DIALOGUE: \nPROMPT: ${Config.defaultPrompt}`;
                }
                const { dialogue, prompt } = parseResponse(raw);
                currentPrompt = prompt;
                pushMem(mapId, eventId, "assistant", dialogue);
                await showMsg(convertTags(dialogue));
                active = await promptContinue();
            }
            interp._waitCount = 0;
      })();
    });
    PluginManager.registerCommand(PLUGIN_NAME, "evaluateEnemyBehavior", function (args) {
      const interp   = this;
      const enemyName= String(args.enemyName         || "Enemy");
      const enemyDesc= String(args.enemyDescription    || "");
      const questId    = String(args.questId             || "");
      const baseSwitch = Number(args.behaviorSwitchBase|| 1);
      const hpVarId    = Number(args.playerHpPercent   || 0);
      const playerHp   = hpVarId > 0 ? $gameVariables.value(hpVarId) : 100;
      interp._waitCount = 9999;
      (async () => {
            let idx = 0;
            try   { idx = await evaluateEnemyBehavior(enemyName, enemyDesc, questId, playerHp); }
            catch (e) { lerr("Enemy eval error:", e.message); idx = 0; }
            for (let i = 0; i < ENEMY_BEHAVIORS.length; i++) {
                $gameSwitches.setValue(baseSwitch + i, i === idx);
            }
            log(`Enemy "${enemyName}" -> ${ENEMY_BEHAVIORS} (Switch ${baseSwitch + idx} = ON)`);
            interp._waitCount = 0;
      })();
    });
    PluginManager.registerCommand(PLUGIN_NAME, "setContextVar", function (args) {
      _ctxVars.push({
            label      : String(args.label      || ""),
            variableId : Number(args.variableId || 0),
            switchId   : Number(args.switchId   || 0),
      });
    });
    PluginManager.registerCommand(PLUGIN_NAME, "clearMemory", function () {
      clearMem($gameMap.mapId(), this._eventId);
      log("Memory cleared for event:", this._eventId);
    });
    PluginManager.registerCommand(PLUGIN_NAME, "clearAllMemory", function () {
      Object.keys(_npcMemory).forEach(k => { _npcMemory = []; });
      log("All NPC memory cleared.");
    });
    //=========================================================================
    // Async helpers
    //=========================================================================
    function getPlayerInput(prompt) {
      return new Promise(res => {
            const w = getInputWin();
            if (!w) { res(null); return; }
            w.activate(prompt, text => res(text), () => res(null));
      });
    }
    // Wraps text into lines of maxChars, then groups lines into pages of maxLines.
    // MZ message window shows ~4 lines, ~50 chars per line with default font.
    function wrapTextToPages(text, maxChars, maxLines) {
      // Step 1: word-wrap into lines
      const words = text.split(" ");
      const lines = [];
      let cur = "";
      for (const word of words) {
            const test = cur ? cur + " " + word : word;
            if (test.length > maxChars) {
                if (cur) lines.push(cur);
                cur = word;
            } else {
                cur = test;
            }
      }
      if (cur) lines.push(cur);
      // Step 2: group lines into pages
      const pages = [];
      for (let i = 0; i < lines.length; i += maxLines) {
            pages.push(lines.slice(i, i + maxLines).join("\n"));
      }
      return pages.length ? pages : ;
    }
    function showMsg(text) {
      return new Promise(res => {
            $gameMessage.clear();
            // 50 chars per line, 4 lines per page - adjust if you use a larger font
            const pages = wrapTextToPages(text, 50, 4);
            pages.forEach(p => $gameMessage.add(p));
            const t = setInterval(() => {
                if (!$gameMessage.isBusy()) { clearInterval(t); res(); }
            }, 100);
      });
    }
    function promptContinue() {
      return new Promise(res => {
            $gameMessage.clear();
            $gameMessage.setChoices(["Continue", "Leave"], 0, 1);
            $gameMessage.setChoiceCallback(i => res(i === 0));
      });
    }
    // splitChunks removed in v2.1 - MZ handles word wrap natively
    //=========================================================================
    // Save / Load
    //=========================================================================
    const _origMake = DataManager.makeSaveContents;
    DataManager.makeSaveContents = function () {
      const c = _origMake.call(this);
      c.groqNpcMemory= _npcMemory;
      c.groqPersonas   = _trainedPersonas;
      c.groqQuests   = _quests;
      c.groqCtxVars    = _ctxVars.slice();
      return c;
    };
    const _origExtract = DataManager.extractSaveContents;
    DataManager.extractSaveContents = function (c) {
      _origExtract.call(this, c);
      if (c.groqNpcMemory) Object.assign(_npcMemory,       c.groqNpcMemory);
      if (c.groqPersonas)Object.assign(_trainedPersonas, c.groqPersonas);
      if (c.groqQuests)    Object.assign(_quests,          c.groqQuests);
      if (c.groqCtxVars)   c.groqCtxVars.forEach(v => { if (!_ctxVars.find(x => x.label === v.label)) _ctxVars.push(v); });
    };
    log(`v2.2 loaded | model:${Config.model} | timeout:${Config.streamTimeout/1000}s | fmt:${Config.enableFmt} | debug:${Config.debugMode}`);
    log("API key present:", Config.apiKey.length > 0, "| length:", Config.apiKey.length);
    log("RAW params.apiKey:", JSON.stringify(params["apiKey"]));
})();








本贴来自国际rpgmaker官方论坛作者:Rpx处,因国际论坛即将永久关站,为了存档多年珍贵资料,署名转载到本论坛存档,由于官方帖子为英文原帖,需要中文翻译请点击论坛顶部切换语言为中文就可以将帖子翻译成中文浏览,方便大家随时查看,原文地址:https://forums.rpgmakerweb.com/threads/ai-dialogue.183612/
页: [1]
查看完整版本: AI Dialogue