Add files via upload

This commit is contained in:
2025-12-31 13:59:51 +08:00
committed by GitHub
parent ec09ed420c
commit 7a0eb06ad0
8 changed files with 1451 additions and 392 deletions

View File

@@ -1,256 +1,348 @@
import { callAi, getApiConfig } from "./api.js";
import { tools, getToolDefinitions } from "./tools.js";
import { ContextManager } from "./context-manager.js";
import { TaskState } from "./task-state.js";
import { MemorySystem } from "./memory-system.js";
export class AgentManager {
constructor() {
this.history = [];
this.executorHistory = [];
this.reviewerHistory = [];
this.executorSystemPrompt = this.buildExecutorSystemPrompt();
this.reviewerSystemPrompt = this.buildReviewerSystemPrompt();
this.contextManager = new ContextManager();
this.taskState = new TaskState();
this.memorySystem = new MemorySystem();
this.currentChid = undefined;
this.currentBookName = undefined;
this.status = 'idle'; // idle, running, paused
this.approvalRequired = false;
this.pendingToolCall = null;
}
setContext(chid, bookName) {
async setContext(chid, bookName) {
this.currentChid = chid;
this.currentBookName = bookName;
this.executorSystemPrompt = this.buildExecutorSystemPrompt();
}
buildReviewerSystemPrompt() {
const toolDefs = getToolDefinitions();
let prompt = `你是一个经验丰富的角色卡设计师和辅导员Reviewer。你的搭档是一个执行力强且富有创造力的 AI 助手Executor
你的目标是根据用户的需求,设计出高质量的角色卡和世界书方案,并指导 Executor 一步步实现。
Executor 拥有以下工具(你不能直接使用,但需要知道它能做什么):
`;
toolDefs.forEach(tool => {
prompt += `- ${tool.name}: ${tool.description}\n`;
});
prompt += `
### 世界书高级设置指南 (World Info Settings)
- **constant (蓝灯)**: 如果为 true该条目将始终被激活并包含在上下文中忽略关键词触发。
- **position (插入位置)**: 决定条目内容在 Prompt 中的位置。
- \`before/after_character_definition\`: 角色定义前后。
- \`before/after_author_note\`: 作者注释前后。
- \`at_depth_as_system\`: 在指定深度作为系统消息插入(推荐)。
- **depth (插入深度)**: 仅当 position 为 \`at_depth_as_system\` 时有效。表示条目距离最新消息的距离(例如 0 为最新4 为倒数第 4 条消息后)。
- **scanDepth (扫描深度)**: 系统扫描关键词的消息范围。例如 2 表示只扫描最近 2 条消息。
- **exclude_recursion**: 如果为 true此条目的内容不会触发其他条目。
- **prevent_recursion**: 如果为 true其他条目的内容不会触发此条目。
你的工作流程:
1. 分析用户需求。
2. 制定详细的实施计划(大纲)。
3. 将计划拆解为 Executor 可以执行的**指导性指令**。
4. 审查 Executor 的执行结果,提出修改意见。
**关键原则:**
- **只给方案,不给成品**:你负责提供创意方向、关键设定点和风格指导,让 Executor 去进行具体的文本创作和扩写。不要直接把完整的角色描述或世界书内容写出来让 Executor 照抄。
- **示例**
- ❌ 错误:“请写入以下描述:她有一头金发,性格傲娇...”
- ✅ 正确:“请为角色撰写一段详细的外貌和性格描述。外貌上要突出她的金发和贵族气质,性格上要体现出‘傲娇’的特点,即外表冷漠但内心渴望被关怀。请发挥你的文采。”
交互规则:
- 当你需要 Executor 执行操作时,请在回复的最后一行使用标签:<instruction>你的指令</instruction>
- **重要**<instruction> 标签内必须是**自然语言指令**。**严禁**直接输出 JSON 代码块作为指令。
- **单步原则**:每次指令**只能包含一个**具体的任务(例如:只创建一个世界书条目,或只更新角色描述)。严禁一次性下达多个任务。
- **字数强制**:在指令中必须明确要求 Executor 进行深度扩写。
- 世界书条目:要求**不低于 300 字**。
- 角色开场白:要求**不低于 1500 字**。
- 当你认为任务已完成或需要用户反馈时,直接回复用户即可,不要包含 <instruction> 标签。
`;
return prompt;
}
buildExecutorSystemPrompt() {
const toolDefs = getToolDefinitions();
let contextInfo = "";
if (bookName && bookName !== 'new') {
try {
// Use return_full: true to get content for ContextManager (RAG)
const bookData = await tools.read_world_info({ book_name: bookName, return_full: true });
const entries = JSON.parse(bookData);
this.contextManager.setWorldInfo(entries);
} catch (e) {
console.error("Failed to load world info for context:", e);
}
}
}
setApprovalRequired(required) {
this.approvalRequired = required;
}
stop() {
this.status = 'idle';
}
async resumeWithApproval(approved, feedback, onStreamUpdate, onPreviewUpdate, onApprovalRequest) {
if (this.status !== 'paused' || !this.pendingToolCall) return;
if (approved) {
this.status = 'running';
await this.executePendingTool(onStreamUpdate, onPreviewUpdate);
this.pendingToolCall = null;
await this.runTaskLoop(onStreamUpdate, onPreviewUpdate, onApprovalRequest);
} else {
this.status = 'running';
this.pendingToolCall = null;
// Add feedback as user message to guide correction
this.history.push({
role: 'user',
content: `[Tool Execution Denied] User Feedback: ${feedback || "No reason provided."}`
});
await this.runTaskLoop(onStreamUpdate, onPreviewUpdate, onApprovalRequest);
}
}
async buildSystemPrompt() {
const toolDefs = getToolDefinitions();
// 1. Role & Objective
let prompt = `You are an expert Character Card Designer and World Builder.
You accomplish a given task iteratively, breaking it down into clear steps and working through them methodically.
IMPORTANT: You MUST speak in Chinese (Simplified) for all your responses and reasoning.
**Core Capabilities**:
- You can create and modify **Single Character Cards**.
- You can create and modify **Multi-Character World Cards** (Group Cards). Do not assume the user only wants a single character.
- You can manage **World Info (Lorebooks)**.
**Workflow**:
1. **Analyze & Explore First**: The World Book Index is provided in the context below. Review it to see what entries exist. If you need the *content* of a specific entry, use \`read_world_entry\` with its UID. Do not use \`read_world_info\` unless you need to refresh the index.
2. **Plan & Execute**: Based on the read data, formulate a plan and execute it step-by-step.
3. **Tool Usage**: Use one tool per turn. Always explain your reasoning in \`<thinking>\` tags before using a tool.
4. **Completion**: When finished, provide a concise summary.
# Memory & Task State
${this.taskState.getPromptContext()}
# Current Context
`;
if (this.currentChid === 'new') {
contextInfo += `**注意:用户希望创建一个新角色。**\n请首先使用 \`create_character\` 工具创建角色。创建成功后,你将获得新的角色 ID请使用该 ID 进行后续操作(如 \`update_character_card\`)。\n`;
prompt += `- **Status**: Creating a NEW character.\n`;
prompt += `- **Action Required**: Use \`create_character\` first to get a Character ID.\n`;
} else if (this.currentChid !== undefined) {
contextInfo += `当前操作的角色ID: ${this.currentChid}\n`;
prompt += `- **Character ID**: ${this.currentChid}\n`;
}
if (this.currentBookName) {
contextInfo += `当前操作的世界书: ${this.currentBookName}\n`;
prompt += `- **World Info Book**: ${this.currentBookName}\n`;
}
let prompt = `你是一个专业的角色卡构建助手Executor。你的目标是根据 Reviewer 的指导和用户的需求,在当前选定的“工作区”(角色卡和世界书)中进行**创作**和修改。
// Dynamic Context Injection (Rules & World Info)
const contextText = this.getLastUserMessage() || "";
const { rules, worldInfo } = this.contextManager.getRelevantContext(contextText);
if (rules.length > 0) {
prompt += `\n# Style Guides & Rules\n`;
rules.forEach(rule => {
prompt += `- ${rule.content}\n`;
});
}
${contextInfo}
if (worldInfo.length > 0) {
prompt += `\n# World Info Context\n`;
worldInfo.forEach(entry => {
prompt += `## Entry: ${entry.keys.join(', ')}\n${entry.content}\n\n`;
});
}
**你的职责:**
1. **理解指令**:仔细阅读 Reviewer 的指导性指令。
2. **深度扩写**这是你的核心任务。Reviewer 给出的只是大纲,你需要将其扩写成丰富、细腻的文学作品。
- **世界书条目**:必须丰富细节,字数**不低于 300 字**。
- **角色开场白**:必须包含环境描写、心理活动、动作细节,字数**不低于 1500 字**。
3. **执行操作**:使用工具将你创作的内容写入系统。
TOOL USE
你拥有以下工具可以使用。你可以使用这些工具来完成任务。每次回复只能使用一个工具。
# Tools
`;
// Environment Details Injection
let envDetails = `\n<environment_details>\n`;
envDetails += `# Current Time\n${new Date().toLocaleString()}\n\n`;
if (this.currentChid !== undefined && this.currentChid !== 'new') {
try {
const charData = await tools.read_character_card({ chid: this.currentChid });
const char = JSON.parse(charData);
envDetails += `# Current Character\n`;
envDetails += `Name: ${char.name}\n`;
envDetails += `Description Length: ${char.description?.length || 0}\n`;
envDetails += `First Message Length: ${char.first_mes?.length || 0}\n`;
envDetails += `Description Snippet: ${char.description?.substring(0, 200).replace(/\n/g, ' ')}...\n\n`;
} catch (e) {
envDetails += `# Current Character\nError reading character: ${e.message}\n\n`;
}
}
if (this.currentBookName && this.currentBookName !== 'new') {
try {
// Get the index for the system prompt
const bookData = await tools.read_world_info({ book_name: this.currentBookName, return_full: false });
const result = JSON.parse(bookData);
envDetails += `# Current World Book Index\n`;
envDetails += `Name: ${this.currentBookName}\n`;
envDetails += `Total Entries: ${result.total_entries}\n`;
envDetails += `Entries List (UID | Name | Keys):\n`;
if (result.entries && result.entries.length > 0) {
result.entries.forEach(entry => {
const keys = Array.isArray(entry.keys) ? entry.keys.join(', ') : entry.keys;
const name = entry.comment || keys || "Unnamed";
envDetails += `- [${entry.uid}] ${name} (Keys: ${keys})\n`;
});
} else {
envDetails += `(No entries found)\n`;
}
envDetails += `\n`;
} catch (e) {
envDetails += `# Current World Book\nError reading world book: ${e.message}\n\n`;
}
}
envDetails += `</environment_details>\n`;
prompt += envDetails;
// 2. Tools
prompt += `\n# Tools\n\n`;
toolDefs.forEach(tool => {
prompt += `## ${tool.name}\n`;
prompt += `Description: ${tool.description}\n`;
prompt += `Parameters:\n${JSON.stringify(tool.parameters, null, 2)}\n\n`;
});
// 3. Tool Use Formatting
prompt += `
### 世界书高级设置指南 (World Info Settings)
- **constant (蓝灯)**: 如果为 true该条目将始终被激活并包含在上下文中忽略关键词触发。
- **position (插入位置)**: 决定条目内容在 Prompt 中的位置。
- \`before/after_character_definition\`: 角色定义前后。
- \`before/after_author_note\`: 作者注释前后。
- \`at_depth_as_system\`: 在指定深度作为系统消息插入(推荐)。
- **depth (插入深度)**: 仅当 position 为 \`at_depth_as_system\` 时有效。表示条目距离最新消息的距离(例如 0 为最新4 为倒数第 4 条消息后)。
- **scanDepth (扫描深度)**: 系统扫描关键词的消息范围。例如 2 表示只扫描最近 2 条消息。
- **exclude_recursion**: 如果为 true此条目的内容不会触发其他条目。
- **prevent_recursion**: 如果为 true其他条目的内容不会触发此条目。
# Tool Use Formatting
工具调用必须使用以下 XML 格式。工具名称包含在开始和结束标签中,每个参数也包含在自己的标签中:
Tool use is formatted using XML-style tags. The tool name is enclosed in opening and closing tags, and each parameter is similarly enclosed within its own set of tags.
<工具名称>
<参数1>值1</参数1>
<参数2>值2</参数2>
<tool_name>
<parameter1_name>value1</parameter1_name>
<parameter2_name>value2</parameter2_name>
...
</工具名称>
</tool_name>
**注意**:对于复杂参数(如数组或对象),请直接在标签内写入 **JSON 字符串**。
**Important**: For complex parameters (arrays or objects), write the **JSON string** directly inside the tag.
例如:
Example:
<write_world_info_entry>
<book_name>MyWorld</book_name>
<entries>[{"key": "Entry1", "content": "..."}]</entries>
</write_world_info_entry>
# Tool Use Guidelines
1. **必须思考 (Mandatory Thinking)**: 在调用任何工具之前,你**必须**先输出一段思考过程,解释你为什么要这样做,以及你打算如何创作内容。请使用 \`<thinking>\` 标签包裹你的思考。**严禁**直接输出工具调用而不进行思考。
2. **单步执行**: 每次回复只能使用**一个**工具。必须等待工具执行结果(成功或失败)后,才能决定并执行下一步操作。
3. **等待确认**: 永远不要假设工具执行成功。必须根据实际返回的结果来判断。
4. **参数完整性**: 确保提供所有必需的参数。
# Capabilities
- 你可以读取和修改当前绑定的世界书World Info
- 你可以读取和修改当前角色的详细信息Name, Description, Personality, Scenario, First Message, etc.)。
- 你可以管理角色的开场白(添加、修改、删除)。
# Rules
1. **工作区**: 你始终在当前选定的角色卡和世界书上下文中操作。
2. **路径**: 如果涉及文件路径(虽然主要通过 API 操作),请认为是相对于工作区的虚拟路径。
3. **完成任务**: 当你认为任务已经完成时,请向用户汇报结果。不要在汇报结果后继续提问。
现在,请开始你的工作。
- **Think First**: Before using any tool, you MUST output a \`<thinking>\` block explaining your plan and reasoning.
- **One Tool Per Turn**: You can only use ONE tool per message. Wait for the result before proceeding.
- **Verify Results**: Always check the [Tool Result] to ensure success. If a tool fails, analyze the error and try again.
- **Detailed Writing**: When writing content (Description, First Message, World Info), be creative and detailed.
- World Info entries: > 300 words.
- First Message: > 1500 words, including environment, psychology, and action.
- **Do not ask for more information than necessary**: Use the tools provided to accomplish the user's request efficiently and effectively.
- **Completion**: When the task is done, provide a final summary to the user.
`;
return prompt;
}
async handleUserMessage(message, onStreamUpdate, onPreviewUpdate) {
this.history.push({ role: 'user', content: message });
this.reviewerHistory.push({ role: 'user', content: message });
await this.runDualAgentLoop(onStreamUpdate, onPreviewUpdate);
}
async runDualAgentLoop(onStreamUpdate, onPreviewUpdate) {
let maxLoops = 3;
let currentLoop = 0;
while (currentLoop < maxLoops) {
currentLoop++;
onStreamUpdate("Reviewer (模型B) 正在思考...", 'system');
const reviewerConfig = getApiConfig('reviewer');
const reviewerMessages = this.contextManager.buildMessages(
this.reviewerSystemPrompt,
this.reviewerHistory,
reviewerConfig.maxTokens
);
let reviewerResponse;
try {
reviewerResponse = await callAi('reviewer', reviewerMessages);
} catch (error) {
onStreamUpdate(`[Reviewer 错误] ${error.message}`, 'system');
return;
}
const instructionMatch = reviewerResponse.match(/<instruction>([\s\S]*?)<\/instruction>/);
const instruction = instructionMatch ? instructionMatch[1].trim() : null;
const displayContent = reviewerResponse.replace(/<instruction>[\s\S]*?<\/instruction>/, '').trim();
if (displayContent) {
onStreamUpdate(displayContent, 'assistant');
this.history.push({ role: 'assistant', content: displayContent });
this.reviewerHistory.push({ role: 'assistant', content: displayContent });
}
if (!instruction) {
break;
}
onStreamUpdate(`Reviewer 指令: ${instruction}`, 'system');
this.executorHistory.push({ role: 'user', content: instruction });
await this.runExecutorLoop(onStreamUpdate, onPreviewUpdate);
const lastExecutorResponse = this.executorHistory[this.executorHistory.length - 1];
if (lastExecutorResponse && lastExecutorResponse.role === 'assistant') {
this.reviewerHistory.push({
role: 'user',
content: `[Executor 执行结果]\n${lastExecutorResponse.content}`
});
getLastUserMessage() {
for (let i = this.history.length - 1; i >= 0; i--) {
if (this.history[i].role === 'user') {
return this.history[i].content;
}
}
return null;
}
async runExecutorLoop(onStreamUpdate, onPreviewUpdate) {
let maxTurns = 5;
async handleUserMessage(message, onStreamUpdate, onPreviewUpdate, onApprovalRequest) {
// Initialize task state if it's the first message or explicitly requested
if (this.history.length === 0) {
this.taskState.init(message);
}
this.history.push({ role: 'user', content: message });
this.status = 'running';
await this.runTaskLoop(onStreamUpdate, onPreviewUpdate, onApprovalRequest);
}
async runTaskLoop(onStreamUpdate, onPreviewUpdate, onApprovalRequest) {
let maxTurns = 20; // Safety limit
let currentTurn = 0;
while (currentTurn < maxTurns) {
while (this.status === 'running' && currentTurn < maxTurns) {
currentTurn++;
const executorConfig = getApiConfig('executor');
// 0. Check Memory/Context
const config = getApiConfig('executor');
const currentTokens = this.contextManager.estimateTokens(JSON.stringify(this.history));
if (this.memorySystem.shouldSummarize(this.history, currentTokens, config.maxTokens)) {
onStreamUpdate("Context limit approaching. Summarizing memory...", 'system');
const summary = await this.memorySystem.summarize(this.history, this.taskState);
if (summary) {
this.taskState.updateSummary(summary);
// Optional: Compress history here. For now, we just rely on the summary being injected.
// A simple compression strategy: Keep the last 5 messages, and replace the rest with a system note.
if (this.history.length > 5) {
const lastMessages = this.history.slice(-5);
this.history = [
{ role: 'system', content: `[History Compressed] Previous conversation has been summarized in the "Memory & Task State" section. Resuming from recent messages.` },
...lastMessages
];
}
}
}
// 1. Build System Prompt (Dynamic)
const systemPrompt = await this.buildSystemPrompt();
// 2. Build Messages
const messages = this.contextManager.buildMessages(
this.executorSystemPrompt,
this.executorHistory,
executorConfig.maxTokens
systemPrompt,
this.history,
config.maxTokens
);
// 3. Call AI
let responseContent;
let fullStreamedContent = "";
try {
responseContent = await callAi('executor', messages);
onStreamUpdate("Thinking...", 'system');
responseContent = await callAi('executor', messages, {}, (chunk) => {
onStreamUpdate(chunk, 'stream-assistant');
fullStreamedContent += chunk;
// Try to parse partial tool call for real-time preview
if (onPreviewUpdate) {
const partialTool = this.parsePartialToolCall(fullStreamedContent);
if (partialTool) {
onPreviewUpdate(partialTool.name, partialTool.arguments, true); // true = isPartial
}
}
});
} catch (error) {
onStreamUpdate(`[Executor 错误] ${error.message}`, 'system');
onStreamUpdate(`[Error] ${error.message}`, 'system');
this.status = 'idle'; // Stop on API error
return;
}
onStreamUpdate(responseContent, 'executor');
this.executorHistory.push({ role: 'assistant', content: responseContent });
if (this.status !== 'running') return; // Check if stopped during await
// 4. Process Response
// Check for truncation (Auto-Continue)
const lastChar = responseContent.trim().slice(-1);
const isTruncated = !['.', '!', '?', '"', "'", '}', ']', '>', '*'].includes(lastChar) && responseContent.length > 100;
if (isTruncated && currentTurn < maxTurns) {
console.log("检测到回复截断,正在自动继续...");
try {
// Append a continue message
const continueMsg = { role: 'user', content: "Continue" };
const continueMessages = [...messages, { role: 'assistant', content: responseContent }, continueMsg];
const continuation = await callAi('executor', continueMessages, {}, (chunk) => {
onStreamUpdate(chunk, 'stream-assistant');
});
responseContent += continuation;
console.log("自动合并接续内容完成");
} catch (e) {
console.warn("Auto-continue failed:", e);
}
}
this.history.push({ role: 'assistant', content: responseContent });
const thinkingMatch = responseContent.match(/<thinking>([\s\S]*?)<\/thinking>/);
if (thinkingMatch) {
onStreamUpdate(thinkingMatch[1].trim(), 'thought');
}
// Clean up content for UI display
let cleanContent = responseContent
.replace(/<thinking(?:\s+[^>]*)?>[\s\S]*?<\/thinking>/gi, '')
.replace(/<\/thinking>/gi, ''); // Remove residual tags
const toolNames = Object.keys(tools);
const toolRegex = new RegExp(`<(${toolNames.join('|')})(?:\\s+[^>]*)?>[\\s\\S]*?<\\/\\1>`, 'gi');
cleanContent = cleanContent.replace(toolRegex, '').trim();
// Update the UI with the final clean content (replacing the raw stream)
if (cleanContent) {
onStreamUpdate(cleanContent, 'assistant');
}
// 5. Parse Tool Call
const toolCall = this.parseToolCall(responseContent);
if (toolCall) {
// Check for duplicate tool calls to prevent loops
if (this.isDuplicateToolCall(toolCall)) {
const warningMsg = `[System Warning] You have just executed this exact tool call (${toolCall.name}). Do not repeat the same action immediately. If you need to check the result again, look at the conversation history. If the previous result was unsatisfactory, try a different approach.`;
this.history.push({ role: 'user', content: warningMsg });
continue;
}
// Inject Context if missing
if (toolCall.name === 'update_character_card' || toolCall.name === 'read_character_card' || toolCall.name === 'edit_character_text' || toolCall.name === 'manage_first_message') {
if (toolCall.arguments.chid === undefined && this.currentChid !== undefined) {
toolCall.arguments.chid = parseInt(this.currentChid);
@@ -262,41 +354,79 @@ TOOL USE
}
}
onStreamUpdate(`[Executor] 执行工具: ${toolCall.name}`, 'system');
let result;
try {
if (tools[toolCall.name]) {
result = await tools[toolCall.name](toolCall.arguments);
if (toolCall.name === 'create_character' && result.includes('ID:')) {
const match = result.match(/ID:\s*(\d+)/);
if (match) {
this.currentChid = parseInt(match[1]);
this.executorSystemPrompt = this.buildExecutorSystemPrompt();
}
}
} else {
result = `Error: Tool '${toolCall.name}' not found.`;
this.pendingToolCall = toolCall;
if (this.approvalRequired) {
this.status = 'paused';
if (onApprovalRequest) {
onApprovalRequest(toolCall.name, toolCall.arguments);
}
} catch (error) {
result = `Error executing tool '${toolCall.name}': ${error.message}`;
}
const toolResultMsg = `[Tool Result for ${toolCall.name}]\n${result}`;
this.executorHistory.push({ role: 'user', content: toolResultMsg });
onStreamUpdate(`[Executor] 工具结果: ${result.substring(0, 100)}...`, 'system');
if (onPreviewUpdate && !result.startsWith('Error')) {
onPreviewUpdate(toolCall.name, toolCall.arguments);
return; // Exit loop, wait for resumeWithApproval
} else {
await this.executePendingTool(onStreamUpdate, onPreviewUpdate);
this.pendingToolCall = null;
}
} else {
break;
this.status = 'idle';
}
}
}
async executePendingTool(onStreamUpdate, onPreviewUpdate) {
const toolCall = this.pendingToolCall;
if (!toolCall) return;
onStreamUpdate(`Executing: ${toolCall.name}`, 'system');
let result;
try {
if (tools[toolCall.name]) {
result = await tools[toolCall.name](toolCall.arguments);
if (toolCall.name === 'create_character' && result.includes('ID:')) {
const match = result.match(/ID:\s*(\d+)/);
if (match) {
this.currentChid = parseInt(match[1]);
}
}
} else {
result = `Error: Tool '${toolCall.name}' not found.`;
}
} catch (error) {
result = `Error executing tool '${toolCall.name}': ${error.message}`;
}
const toolResultMsg = `[Tool Result for ${toolCall.name}]\n${result}`;
this.history.push({ role: 'user', content: toolResultMsg });
if (onPreviewUpdate && !result.startsWith('Error')) {
onPreviewUpdate(toolCall.name, toolCall.arguments);
}
}
isDuplicateToolCall(toolCall) {
if (this.history.length < 3) return false;
// History structure:
// ...
// Assistant: <tool>A</tool> (index -3)
// User: [Tool Result A] (index -2)
// Assistant: <tool>A</tool> (index -1, current)
const prevAssistantMsg = this.history[this.history.length - 3];
const prevUserMsg = this.history[this.history.length - 2];
if (prevAssistantMsg.role === 'assistant' && prevUserMsg.role === 'user' && prevUserMsg.content.startsWith('[Tool Result')) {
const prevToolCall = this.parseToolCall(prevAssistantMsg.content);
if (prevToolCall &&
prevToolCall.name === toolCall.name &&
JSON.stringify(prevToolCall.arguments) === JSON.stringify(toolCall.arguments)) {
return true;
}
}
return false;
}
parseToolCall(content) {
const toolNames = Object.keys(tools);
for (const name of toolNames) {
@@ -327,10 +457,41 @@ TOOL USE
}
return null;
}
parsePartialToolCall(content) {
const toolNames = Object.keys(tools);
for (const name of toolNames) {
// Look for the opening tag
const openTagRegex = new RegExp(`<${name}>`);
const openMatch = content.match(openTagRegex);
if (openMatch) {
// We found a tool start. Now try to extract params, even if incomplete.
const startIndex = openMatch.index + openMatch[0].length;
const toolContent = content.slice(startIndex);
const args = {};
// Match complete tags or tags that are still open (at the end)
// <param>value...</param> OR <param>value...
const paramRegex = /<(\w+)>([\s\S]*?)(?:<\/\1>|$)/g;
let paramMatch;
while ((paramMatch = paramRegex.exec(toolContent)) !== null) {
const paramName = paramMatch[1];
let paramValue = paramMatch[2];
// Don't try to JSON parse partial content here, leave it as string
// The UI handler will deal with partial JSON
args[paramName] = paramValue;
}
return { name, arguments: args };
}
}
return null;
}
clearHistory() {
this.history = [];
this.executorHistory = [];
this.reviewerHistory = [];
}
}

View File

@@ -24,7 +24,7 @@ export function setApiConfig(role, config) {
extension_settings[extensionName][configKey] = { ...getApiConfig(role), ...config };
}
export async function callAi(role, messages, options = {}) {
export async function callAi(role, messages, options = {}, onChunk = null) {
const config = { ...getApiConfig(role), ...options };
const roleName = role === 'executor' ? '执行者(模型A)' : '规划者(模型B)';
@@ -32,7 +32,7 @@ export async function callAi(role, messages, options = {}) {
throw new Error(`[自动构建器] ${roleName} API 配置不完整,请检查 URL、Key 和模型设置。`);
}
console.log(`[自动构建器] 正在调用 AI (${roleName})...`, { model: config.model, messagesCount: messages.length });
console.log(`[自动构建器] 正在调用 AI (${roleName})...`, { model: config.model, messagesCount: messages.length, stream: !!onChunk });
const body = {
chat_completion_source: 'openai',
@@ -40,8 +40,8 @@ export async function callAi(role, messages, options = {}) {
model: config.model,
reverse_proxy: config.apiUrl,
proxy_password: config.apiKey,
stream: false,
max_tokens: config.maxTokens,
stream: !!onChunk,
max_tokens: config.maxTokens > 0 ? config.maxTokens : undefined,
temperature: config.temperature,
top_p: 1,
custom_prompt_post_processing: 'strict',
@@ -62,18 +62,54 @@ export async function callAi(role, messages, options = {}) {
throw new Error(`API 请求失败: ${response.status} - ${errorText}`);
}
const responseData = await response.json();
if (!responseData || !responseData.choices || responseData.choices.length === 0) {
if (responseData.error) {
throw new Error(`API 返回错误: ${responseData.error.message || JSON.stringify(responseData.error)}`);
}
throw new Error('API 返回了空响应。');
}
if (onChunk) {
const reader = response.body.getReader();
const decoder = new TextDecoder("utf-8");
let fullContent = "";
let buffer = "";
const content = responseData.choices[0].message?.content;
console.log(`[自动构建器] AI (${roleName}) 响应接收成功。长度: ${content?.length}`);
return content;
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split('\n');
buffer = lines.pop(); // Keep incomplete line in buffer
for (const line of lines) {
const trimmedLine = line.trim();
if (trimmedLine.startsWith('data: ')) {
const dataStr = trimmedLine.slice(6).trim();
if (dataStr === '[DONE]') continue;
try {
const data = JSON.parse(dataStr);
const delta = data.choices[0].delta?.content || "";
if (delta) {
fullContent += delta;
onChunk(delta);
}
} catch (e) {
// Ignore parse errors for partial chunks
}
}
}
}
console.log(`[自动构建器] AI (${roleName}) 流式响应结束。长度: ${fullContent.length}`);
return fullContent;
} else {
const responseData = await response.json();
if (!responseData || !responseData.choices || responseData.choices.length === 0) {
if (responseData.error) {
throw new Error(`API 返回错误: ${responseData.error.message || JSON.stringify(responseData.error)}`);
}
throw new Error('API 返回了空响应。');
}
const content = responseData.choices[0].message?.content;
console.log(`[自动构建器] AI (${roleName}) 响应接收成功。长度: ${content?.length}`);
return content;
}
} catch (error) {
console.error(`[自动构建器] AI (${roleName}) 调用失败:`, error);
@@ -86,10 +122,10 @@ export async function testConnection(role) {
const response = await callAi(role, [
{ role: 'user', content: 'Hi' }
], { maxTokens: 10 });
return !!response;
return { success: !!response };
} catch (error) {
console.error(`[自动构建器] ${role} 连接测试失败:`, error);
return false;
return { success: false, error: error.message };
}
}

View File

@@ -121,6 +121,11 @@ export async function createNewCharacter(name) {
formData.append('depth_prompt_depth', '4');
formData.append('depth_prompt_role', 'system');
const base64Png = "iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAQAAAC1HAwCAAAAC0lEQVR42mNkYAAAAAYAAjCB0C8AAAAASUVORK5CYII=";
const res = await fetch(`data:image/png;base64,${base64Png}`);
const blob = await res.blob();
formData.append('avatar', blob, 'default.png');
const response = await fetch('/api/characters/create', {
method: 'POST',
headers: getRequestHeaders({ omitContentType: true }),

View File

@@ -1,7 +1,55 @@
export class ContextManager {
constructor() {
this.keepToolOutputTurns = 3;
this.tokenLimit = 12000;
this.keepToolOutputTurns = 5;
this.tokenLimit = 100000;
this.rules = [];
this.worldInfo = [];
}
addRule(rule) {
this.rules.push({
id: rule.id || Date.now().toString(),
keyword: rule.keyword || null,
content: rule.content,
enabled: rule.enabled !== undefined ? rule.enabled : true
});
}
setWorldInfo(entries) {
this.worldInfo = entries.map(entry => {
let keys = [];
if (Array.isArray(entry.key)) {
keys = entry.key;
} else if (typeof entry.key === 'string') {
keys = entry.key.split(',').map(k => k.trim()).filter(k => k);
}
return {
id: entry.uid,
keys: keys,
content: entry.content,
enabled: entry.enabled !== false
};
});
}
getRelevantContext(contextText) {
const relevantRules = this.rules.filter(rule => {
if (!rule.enabled) return false;
if (!rule.keyword) return true;
return contextText.includes(rule.keyword);
});
const relevantWorldInfo = this.worldInfo.filter(entry => {
if (!entry.enabled) return false;
if (!entry.keys || entry.keys.length === 0) return false;
return entry.keys.some(key => contextText.includes(key));
});
return {
rules: relevantRules,
worldInfo: relevantWorldInfo
};
}
estimateTokens(text) {

View File

@@ -0,0 +1,54 @@
import { callAi, getApiConfig } from "./api.js";
export class MemorySystem {
constructor() {
this.summarizePrompt = `
The current conversation context is growing large. Your task is to create a comprehensive, structured summary of the character/world generation process so far.
This summary will be used as the "Memory" for the next steps, so it must be detailed enough to prevent information loss.
Please summarize the following:
1. **Core Identity**: Name, Age, Gender, Role, etc.
2. **Personality & Traits**: Key personality keywords, behavioral quirks, speech patterns.
3. **Appearance**: Physical description, clothing, accessories.
4. **Background & Lore**: Backstory, world setting, important relationships.
5. **Current Progress**: What has been completed, what is currently being worked on, and what is left to do.
6. **User Preferences**: Any specific constraints or requests made by the user (e.g., "Make her tsundere", "Don't use modern technology").
Format your response as a structured Markdown block.
`;
}
async summarize(history, taskState) {
const config = getApiConfig('executor');
const contextMsg = `
[System Note]: The following is the current Task State. Use this to inform your summary.
${taskState.getPromptContext()}
`;
const messages = [
{ role: 'system', content: this.summarizePrompt },
...history.slice(-10),
{ role: 'user', content: `Please summarize the session based on the history above. ${contextMsg}` }
];
try {
const response = await callAi('executor', messages, {
max_tokens: 2000,
temperature: 0.5
});
return response;
} catch (error) {
console.error("Failed to generate summary:", error);
return null;
}
}
shouldSummarize(history, tokenCount, maxTokens) {
const tokenUsageRatio = tokenCount / maxTokens;
if (tokenUsageRatio > 0.7) return true;
if (history.length > 15) return true;
return false;
}
}

View File

@@ -0,0 +1,86 @@
export class TaskState {
constructor() {
this.reset();
}
reset() {
this.originalRequest = "";
this.currentGoal = "";
this.completedSteps = []; // Array of strings
this.pendingSteps = []; // Array of strings
this.summary = ""; // The structured summary of the character/world so far
this.generatedData = {}; // Key-value pairs of generated attributes (e.g., name, personality)
this.lastSummaryTimestamp = 0;
}
init(request) {
this.reset();
this.originalRequest = request;
this.currentGoal = "Analyze request and plan steps";
this.lastSummaryTimestamp = Date.now();
}
updateSummary(newSummary) {
this.summary = newSummary;
this.lastSummaryTimestamp = Date.now();
}
addCompletedStep(step) {
this.completedSteps.push(step);
}
setPendingSteps(steps) {
this.pendingSteps = steps;
}
setCurrentGoal(goal) {
this.currentGoal = goal;
}
updateGeneratedData(key, value) {
this.generatedData[key] = value;
}
getPromptContext() {
let context = `\n# Task State\n`;
context += `- **Original Request**: ${this.originalRequest}\n`;
context += `- **Current Goal**: ${this.currentGoal}\n`;
if (this.completedSteps.length > 0) {
context += `- **Completed Steps**:\n${this.completedSteps.map(s => ` - ${s}`).join('\n')}\n`;
}
if (this.pendingSteps.length > 0) {
context += `- **Pending Steps**:\n${this.pendingSteps.map(s => ` - ${s}`).join('\n')}\n`;
}
if (this.summary) {
context += `\n# Memory & Context Summary\n${this.summary}\n`;
}
return context;
}
toJSON() {
return {
originalRequest: this.originalRequest,
currentGoal: this.currentGoal,
completedSteps: this.completedSteps,
pendingSteps: this.pendingSteps,
summary: this.summary,
generatedData: this.generatedData,
lastSummaryTimestamp: this.lastSummaryTimestamp
};
}
fromJSON(json) {
if (!json) return;
this.originalRequest = json.originalRequest || "";
this.currentGoal = json.currentGoal || "";
this.completedSteps = json.completedSteps || [];
this.pendingSteps = json.pendingSteps || [];
this.summary = json.summary || "";
this.generatedData = json.generatedData || {};
this.lastSummaryTimestamp = json.lastSummaryTimestamp || 0;
}
}

View File

@@ -3,9 +3,40 @@ import * as charApi from "./char-api.js";
export const tools = {
read_world_info: async ({ book_name }) => {
read_world_info: async ({ book_name, return_full = false }) => {
const entries = await amilyHelper.getLorebookEntries(book_name);
return JSON.stringify(entries, null, 2);
if (return_full) {
return JSON.stringify(entries, null, 2);
}
const summary = entries.map(e => {
let keys = e.key;
if (Array.isArray(keys)) keys = keys.join(', ');
return {
uid: e.uid,
keys: keys,
comment: e.comment || keys || "Unnamed Entry",
};
});
return JSON.stringify({
info: "Index of world book entries. Use 'read_world_entry' with 'uid' to read specific content.",
total_entries: entries.length,
entries: summary
}, null, 2);
},
read_world_entry: async ({ book_name, uid }) => {
const entries = await amilyHelper.getLorebookEntries(book_name);
const entry = entries.find(e => String(e.uid) === String(uid));
if (!entry) {
return `Entry with UID ${uid} not found in world book "${book_name}".`;
}
return JSON.stringify(entry, null, 2);
},
write_world_info_entry: async ({ book_name, entries }) => {
@@ -74,10 +105,15 @@ export const tools = {
const finalUpdates = args.updates || updates;
const success = charApi.updateCharacter(chid, finalUpdates);
return success ? "角色卡更新成功。" : "更新角色卡失败。";
if (success) {
const updatedFields = Object.keys(finalUpdates).join(', ');
return `角色卡更新成功 [ID: ${chid}]。已更新字段: ${updatedFields}`;
} else {
return "更新角色卡失败。";
}
},
edit_character_text: async ({ chid, field, search, replace }) => {
edit_character_text: async ({ chid, field, diff }) => {
const char = charApi.getCharacter(chid);
if (!char) return "未找到角色。";
@@ -86,14 +122,27 @@ export const tools = {
return `无效的字段。允许的字段: ${allowedFields.join(', ')}`;
}
const originalText = char[field] || '';
if (!originalText.includes(search)) {
return `在字段 '${field}' 中未找到搜索文本。`;
let content = char[field] || '';
const changes = diff.split('------- SEARCH');
// Remove the first empty split if any
if (changes[0].trim() === '') changes.shift();
for (const change of changes) {
const parts = change.split('=======');
if (parts.length !== 2) continue;
const searchBlock = parts[0].trim();
const replaceBlock = parts[1].split('+++++++ REPLACE')[0].trim();
if (!content.includes(searchBlock)) {
return `错误: 在字段 '${field}' 中未找到以下搜索块:\n${searchBlock}`;
}
content = content.replace(searchBlock, replaceBlock);
}
const newText = originalText.replace(search, replace);
const success = charApi.updateCharacter(chid, { [field]: newText });
const success = charApi.updateCharacter(chid, { [field]: content });
return success ? `字段 '${field}' 更新成功。` : `更新字段 '${field}' 失败。`;
},
@@ -127,7 +176,7 @@ export function getToolDefinitions() {
return [
{
name: "read_world_info",
description: "Read all entries from a specific world book.",
description: "Read the index (list of entries with keys and comments) of a world book. Does NOT return full content.",
parameters: {
type: "object",
properties: {
@@ -136,6 +185,18 @@ export function getToolDefinitions() {
required: ["book_name"]
}
},
{
name: "read_world_entry",
description: "Read the full content of a specific world book entry.",
parameters: {
type: "object",
properties: {
book_name: { type: "string", description: "The name of the world book." },
uid: { type: "number", description: "The UID of the entry to read." }
},
required: ["book_name", "uid"]
}
},
{
name: "write_world_info_entry",
description: "Create or update entries in a world book.",
@@ -207,16 +268,18 @@ export function getToolDefinitions() {
},
{
name: "edit_character_text",
description: "Edit a specific text field of a character using search and replace.",
description: "Edit a specific text field of a character using SEARCH/REPLACE blocks.",
parameters: {
type: "object",
properties: {
chid: { type: "number", description: "Character ID." },
field: { type: "string", enum: ["description", "personality", "scenario", "first_mes", "mes_example"], description: "The field to edit." },
search: { type: "string", description: "The exact text to find." },
replace: { type: "string", description: "The text to replace with." }
diff: {
type: "string",
description: "One or more SEARCH/REPLACE blocks following this exact format:\n------- SEARCH\n[exact content to find]\n=======\n[new content to replace with]\n+++++++ REPLACE"
}
},
required: ["chid", "field", "search", "replace"]
required: ["chid", "field", "diff"]
}
},
{

File diff suppressed because it is too large Load Diff