mirror of
https://github.com/vegu-ai/talemate.git
synced 2025-12-25 07:59:36 +01:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
73240b5791 |
@@ -102,14 +102,14 @@ Will be updated as i test more models and over time.
|
||||
|
||||
| Model Name | Type | Notes |
|
||||
|-------------------------------|-----------------|-------------------------------------------------------------------------------------------------------------------|
|
||||
| [GPT-4](https://platform.openai.com/) | Remote | Still the best for consistency and reasoning, but is heavily censored, while talemate will send a general "decensor" system prompt, depending on the type of content you want to roleplay, there is a chance your key will be banned. **If you do use this make sure to monitor your api usage, talemate tends to send a lot more requests than other roleplaying applications.** |
|
||||
| [Nous Hermes LLama2](https://huggingface.co/TheBloke/Nous-Hermes-Llama2-GPTQ) | 13B model | My go-to model for 13B parameters. It's good at roleplay and also smart enough to handle the world state and narrative tools. A 13B model loaded via exllama also allows you run chromadb with the xl instructor embeddings off of a single 4090. |
|
||||
| [Xwin-LM-13B](https://huggingface.co/TheBloke/Xwin-LM-13B-V0.1-GPTQ) | 13B model | Really strong model, roleplaying capability still needs more testing |
|
||||
| [MythoMax](https://huggingface.co/TheBloke/MythoMax-L2-13B-GPTQ) | 13B model | Similar quality to Hermes LLama2, but a bit more creative. Rarely fails on JSON responses. |
|
||||
| [Synthia v1.2 34B](https://huggingface.co/TheBloke/Synthia-34B-v1.2-GPTQ) | 34B model | Cannot be run at full context together with chromadb instructor models on a single 4090. But a great choice if you're running chromadb with the default embeddings (or on cpu). |
|
||||
| [Xwin-LM-70B](https://huggingface.co/Xwin-LM/Xwin-LM-70B-V0.1) | 70B model | Great choice if you have the hardware to run it (or can rent it). |
|
||||
| [Xwin-LM-70B](https://huggingface.co/TheBloke/Xwin-LM-70B-V0.1-GPTQ) | 70B model | Great choice if you have the hardware to run it (or can rent it). |
|
||||
| [Synthia v1.2 70B](https://huggingface.co/TheBloke/Synthia-70B-v1.2-GPTQ) | 70B model | Great choice if you have the hardware to run it (or can rent it). |
|
||||
|
||||
I have not included OpenAI's gpt-3.5-turbo in this list, since it is really inconsistent with JSON responses, plus its probably still just as heavily censored as GPT-4.
|
||||
| [GPT-4](https://platform.openai.com/) | Remote | Still the best for consistency and reasoning, but is heavily censored. While talemate will send a general "decensor" system prompt, depending on the type of content you want to roleplay, there is a chance your key will be banned. **If you do use this make sure to monitor your api usage, talemate tends to send a lot more requests than other roleplaying applications.** |
|
||||
| [GPT-3.5-turbo](https://platform.openai.com/) | Remote | It's really inconsistent with JSON responses, plus its probably still just as heavily censored as GPT-4. If you want to run it i'd suggest running it for the conversation agent, and use GPT-4 for the other agents. **If you do use this make sure to monitor your api usage, talemate tends to send a lot more requests than other roleplaying applications.** |
|
||||
|
||||
I have not tested with Llama 1 models in a while, Lazarus was really good at roleplay, but started failing on JSON requirements.
|
||||
|
||||
|
||||
@@ -1,7 +1,15 @@
|
||||
## ChromaDB
|
||||
# ChromaDB
|
||||
|
||||
Talemate uses ChromaDB to maintain long-term memory. The default embeddings used are really fast but also not incredibly accurate. If you want to use more accurate embeddings you can use the instructor embeddings or the openai embeddings. See below for instructions on how to enable these.
|
||||
|
||||
In my testing so far, instructor-xl has proved to be the most accurate (even more-so than openai)
|
||||
|
||||
## Local instructor embeddings
|
||||
|
||||
If you want chromaDB to use the more accurate (but much slower) instructor embeddings add the following to `config.yaml`:
|
||||
|
||||
**Note**: The `xl` model takes a while to load even with cuda. Expect a minute of loading time on the first scene you load.
|
||||
|
||||
```yaml
|
||||
chromadb:
|
||||
embeddings: instructor
|
||||
@@ -9,17 +17,23 @@ chromadb:
|
||||
instructor_model: hkunlp/instructor-xl
|
||||
```
|
||||
|
||||
### Instructor embedding models
|
||||
|
||||
- `hkunlp/instructor-base` (smallest / fastest)
|
||||
- `hkunlp/instructor-large`
|
||||
- `hkunlp/instructor-xl` (largest / slowest) - requires about 5GB of memory
|
||||
|
||||
You will need to restart the backend for this change to take effect.
|
||||
|
||||
**NOTE** - The first time you do this it will need to download the instructor model you selected. This may take a while, and the talemate backend will be un-responsive during that time.
|
||||
|
||||
Once the download is finished, if talemate is still un-responsive, try reloading the front-end to reconnect. When all fails just restart the backend as well.
|
||||
Once the download is finished, if talemate is still un-responsive, try reloading the front-end to reconnect. When all fails just restart the backend as well. I'll try to make this more robust in the future.
|
||||
|
||||
### GPU support
|
||||
|
||||
If you want to use the instructor embeddings with GPU support, you will need to install pytorch with CUDA support.
|
||||
|
||||
To do this on windows, run `install-pytorch-cuda.bat` from the project root. Then change your device in the config to `cuda`:
|
||||
To do this on windows, run `install-pytorch-cuda.bat` from the project directory. Then change your device in the config to `cuda`:
|
||||
|
||||
```yaml
|
||||
chromadb:
|
||||
@@ -28,8 +42,20 @@ chromadb:
|
||||
instructor_model: hkunlp/instructor-xl
|
||||
```
|
||||
|
||||
Instructor embedding models:
|
||||
## OpenAI embeddings
|
||||
|
||||
- `hkunlp/instructor-base` (smallest / fastest)
|
||||
- `hkunlp/instructor-large`
|
||||
- `hkunlp/instructor-xl` (largest / slowest) - requires about 5GB of GPU memory
|
||||
First make sure your openai key is specified in the `config.yaml` file
|
||||
|
||||
```yaml
|
||||
openai:
|
||||
api_key: <your-key-here>
|
||||
```
|
||||
|
||||
Then add the following to `config.yaml` for chromadb:
|
||||
|
||||
```yaml
|
||||
chromadb:
|
||||
embeddings: openai
|
||||
```
|
||||
|
||||
**Note**: As with everything openai, using this isn't free. It's way cheaper than their text completion though. ALSO - if you send super explicit content they may flag / ban your key, so keep that in mind (i hear they usually send warnings first though), and always monitor your usage on their dashboard.
|
||||
909
poetry.lock
generated
909
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -4,7 +4,7 @@ build-backend = "poetry.masonry.api"
|
||||
|
||||
[tool.poetry]
|
||||
name = "talemate"
|
||||
version = "0.9.0"
|
||||
version = "0.10.0"
|
||||
description = "AI-backed roleplay and narrative tools"
|
||||
authors = ["FinalWombat"]
|
||||
license = "GNU Affero General Public License v3.0"
|
||||
@@ -35,6 +35,7 @@ websockets = "^11.0.3"
|
||||
structlog = "^23.1.0"
|
||||
runpod = "==1.2.0"
|
||||
nest_asyncio = "^1.5.7"
|
||||
isodate = ">=0.6.1"
|
||||
|
||||
# ChromaDB
|
||||
chromadb = ">=0.4,<1"
|
||||
|
||||
@@ -4,7 +4,29 @@
|
||||
"name": "Infinity Quest",
|
||||
"history": [],
|
||||
"environment": "scene",
|
||||
"archived_history": [],
|
||||
"ts": "P1Y",
|
||||
"archived_history": [
|
||||
{
|
||||
"text": "Captain Elmer and Kaira first met during their rigorous training for the Infinity Quest mission. Their initial interactions were marked by a sense of mutual respect and curiosity.",
|
||||
"ts": "PT1S"
|
||||
},
|
||||
{
|
||||
"text": "Over the course of several months, as they trained together, Elmer and Kaira developed a strong bond. They often spent their free time discussing their dreams of exploring the cosmos.",
|
||||
"ts": "P3M"
|
||||
},
|
||||
{
|
||||
"text": "During a simulated mission, the Starlight Nomad encountered a sudden system malfunction. Elmer and Kaira worked tirelessly together to resolve the issue and avert a potential disaster. This incident strengthened their trust in each other's abilities.",
|
||||
"ts": "P6M"
|
||||
},
|
||||
{
|
||||
"text": "As they ventured further into uncharted space, the crew faced a perilous encounter with a hostile alien species. Elmer and Kaira's coordinated efforts were instrumental in negotiating a peaceful resolution and avoiding conflict.",
|
||||
"ts": "P8M"
|
||||
},
|
||||
{
|
||||
"text": "One memorable evening, while gazing at the stars through the ship's observation deck, Elmer and Kaira shared personal stories from their past. This intimate conversation deepened their connection and understanding of each other.",
|
||||
"ts": "P11M"
|
||||
}
|
||||
],
|
||||
"character_states": {},
|
||||
"characters": [
|
||||
{
|
||||
|
||||
@@ -2,4 +2,4 @@ from .agents import Agent
|
||||
from .client import TextGeneratorWebuiClient
|
||||
from .tale_mate import *
|
||||
|
||||
VERSION = "0.9.0"
|
||||
VERSION = "0.10.0"
|
||||
@@ -158,23 +158,16 @@ class ConversationAgent(Agent):
|
||||
|
||||
def clean_result(self, result, character):
|
||||
|
||||
|
||||
log.debug("clean result", result=result)
|
||||
|
||||
if "#" in result:
|
||||
result = result.split("#")[0]
|
||||
|
||||
result = result.replace("\n", "__LINEBREAK__").strip()
|
||||
|
||||
# Removes partial sentence at the end
|
||||
result = re.sub(r"[^\.\?\!\*]+(\n|$)", "", result)
|
||||
|
||||
result = result.replace(" :", ":")
|
||||
result = result.strip().strip('"').strip()
|
||||
result = result.replace("[", "*").replace("]", "*")
|
||||
result = result.replace("(", "*").replace(")", "*")
|
||||
result = result.replace("**", "*")
|
||||
|
||||
result = result.replace("__LINEBREAK__", "\n")
|
||||
|
||||
# if there is an uneven number of '*' add one to the end
|
||||
|
||||
@@ -237,7 +230,10 @@ class ConversationAgent(Agent):
|
||||
total_result = total_result.split("#")[0]
|
||||
|
||||
# Removes partial sentence at the end
|
||||
total_result = re.sub(r"[^\.\?\!\*]+(\n|$)", "", total_result)
|
||||
total_result = util.strip_partial_sentences(total_result)
|
||||
|
||||
# Remove "{character.name}:" - all occurences
|
||||
total_result = total_result.replace(f"{character.name}:", "")
|
||||
|
||||
if total_result.count("*") % 2 == 1:
|
||||
total_result += "*"
|
||||
|
||||
@@ -50,14 +50,13 @@ class MemoryAgent(Agent):
|
||||
def close_db(self):
|
||||
raise NotImplementedError()
|
||||
|
||||
async def add(self, text, character=None, uid=None):
|
||||
async def add(self, text, character=None, uid=None, ts:str=None, **kwargs):
|
||||
if not text:
|
||||
return
|
||||
|
||||
log.debug("memory add", text=text, character=character, uid=uid)
|
||||
await self._add(text, character=character, uid=uid)
|
||||
await self._add(text, character=character, uid=uid, ts=ts, **kwargs)
|
||||
|
||||
async def _add(self, text, character=None):
|
||||
async def _add(self, text, character=None, ts:str=None, **kwargs):
|
||||
raise NotImplementedError()
|
||||
|
||||
async def add_many(self, objects: list[dict]):
|
||||
@@ -79,7 +78,7 @@ class MemoryAgent(Agent):
|
||||
return self.db.get(id)
|
||||
|
||||
def on_archive_add(self, event: events.ArchiveEvent):
|
||||
asyncio.ensure_future(self.add(event.text, uid=event.memory_id))
|
||||
asyncio.ensure_future(self.add(event.text, uid=event.memory_id, ts=event.ts, typ="history"))
|
||||
|
||||
def on_character_state(self, event: events.CharacterStateEvent):
|
||||
asyncio.ensure_future(
|
||||
@@ -256,7 +255,7 @@ class ChromaDBMemoryAgent(MemoryAgent):
|
||||
|
||||
self.db_client = chromadb.Client(Settings(anonymized_telemetry=False))
|
||||
|
||||
openai_key = self.config.get("openai").get("api_key") or os.environ.get("OPENAI_API_KEY"),
|
||||
openai_key = self.config.get("openai").get("api_key") or os.environ.get("OPENAI_API_KEY")
|
||||
|
||||
if openai_key and self.USE_OPENAI:
|
||||
log.info(
|
||||
@@ -300,25 +299,35 @@ class ChromaDBMemoryAgent(MemoryAgent):
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
async def _add(self, text, character=None, uid=None):
|
||||
async def _add(self, text, character=None, uid=None, ts:str=None, **kwargs):
|
||||
metadatas = []
|
||||
ids = []
|
||||
|
||||
await self.emit_status(processing=True)
|
||||
|
||||
if character:
|
||||
metadatas.append({"character": character.name, "source": "talemate"})
|
||||
meta = {"character": character.name, "source": "talemate"}
|
||||
if ts:
|
||||
meta["ts"] = ts
|
||||
meta.update(kwargs)
|
||||
metadatas.append(meta)
|
||||
self.memory_tracker.setdefault(character.name, 0)
|
||||
self.memory_tracker[character.name] += 1
|
||||
id = uid or f"{character.name}-{self.memory_tracker[character.name]}"
|
||||
ids = [id]
|
||||
else:
|
||||
metadatas.append({"character": "__narrator__", "source": "talemate"})
|
||||
meta = {"character": "__narrator__", "source": "talemate"}
|
||||
if ts:
|
||||
meta["ts"] = ts
|
||||
meta.update(kwargs)
|
||||
metadatas.append(meta)
|
||||
self.memory_tracker.setdefault("__narrator__", 0)
|
||||
self.memory_tracker["__narrator__"] += 1
|
||||
id = uid or f"__narrator__-{self.memory_tracker['__narrator__']}"
|
||||
ids = [id]
|
||||
|
||||
log.debug("chromadb agent add", text=text, meta=meta, id=id)
|
||||
|
||||
self.db.upsert(documents=[text], metadatas=metadatas, ids=ids)
|
||||
|
||||
await self.emit_status(processing=False)
|
||||
@@ -341,7 +350,6 @@ class ChromaDBMemoryAgent(MemoryAgent):
|
||||
metadatas.append(meta)
|
||||
uid = obj.get("id", f"{character}-{self.memory_tracker[character]}")
|
||||
ids.append(uid)
|
||||
|
||||
self.db.upsert(documents=documents, metadatas=metadatas, ids=ids)
|
||||
|
||||
await self.emit_status(processing=False)
|
||||
@@ -371,14 +379,27 @@ class ChromaDBMemoryAgent(MemoryAgent):
|
||||
#log.debug("crhomadb agent get", text=text, where=where)
|
||||
|
||||
_results = self.db.query(query_texts=[text], where=where)
|
||||
|
||||
|
||||
results = []
|
||||
|
||||
for i in range(len(_results["distances"][0])):
|
||||
await asyncio.sleep(0.001)
|
||||
distance = _results["distances"][0][i]
|
||||
|
||||
doc = _results["documents"][0][i]
|
||||
meta = _results["metadatas"][0][i]
|
||||
ts = meta.get("ts")
|
||||
|
||||
if distance < 1:
|
||||
results.append(_results["documents"][0][i])
|
||||
|
||||
try:
|
||||
date_prefix = util.iso8601_diff_to_human(ts, self.scene.ts)
|
||||
except Exception:
|
||||
log.error("chromadb agent", error="failed to get date prefix", ts=ts, scene_ts=self.scene.ts)
|
||||
date_prefix = None
|
||||
|
||||
if date_prefix:
|
||||
doc = f"{date_prefix}: {doc}"
|
||||
results.append(doc)
|
||||
else:
|
||||
break
|
||||
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import traceback
|
||||
from typing import TYPE_CHECKING, Callable, List, Optional, Union
|
||||
|
||||
import talemate.data_objects as data_objects
|
||||
import talemate.util as util
|
||||
from talemate.prompts import Prompt
|
||||
from talemate.scene_message import DirectorMessage
|
||||
from talemate.scene_message import DirectorMessage, TimePassageMessage
|
||||
|
||||
from .base import Agent, set_processing
|
||||
from .registry import register
|
||||
@@ -14,6 +15,7 @@ from .registry import register
|
||||
import structlog
|
||||
|
||||
import time
|
||||
import re
|
||||
|
||||
log = structlog.get_logger("talemate.agents.summarize")
|
||||
|
||||
@@ -40,6 +42,16 @@ class SummarizeAgent(Agent):
|
||||
super().connect(scene)
|
||||
scene.signals["history_add"].connect(self.on_history_add)
|
||||
|
||||
def clean_result(self, result):
|
||||
if "#" in result:
|
||||
result = result.split("#")[0]
|
||||
|
||||
# Removes partial sentence at the end
|
||||
result = re.sub(r"[^\.\?\!]+(\n|$)", "", result)
|
||||
result = result.strip()
|
||||
|
||||
return result
|
||||
|
||||
@set_processing
|
||||
async def build_archive(self, scene):
|
||||
end = None
|
||||
@@ -49,16 +61,36 @@ class SummarizeAgent(Agent):
|
||||
recent_entry = None
|
||||
else:
|
||||
recent_entry = scene.archived_history[-1]
|
||||
start = recent_entry["end"] + 1
|
||||
start = recent_entry.get("end", 0) + 1
|
||||
|
||||
token_threshold = 1300
|
||||
token_threshold = 1500
|
||||
tokens = 0
|
||||
dialogue_entries = []
|
||||
ts = "PT0S"
|
||||
time_passage_termination = False
|
||||
|
||||
if recent_entry:
|
||||
ts = recent_entry.get("ts", ts)
|
||||
|
||||
for i in range(start, len(scene.history)):
|
||||
dialogue = scene.history[i]
|
||||
if isinstance(dialogue, DirectorMessage):
|
||||
if i == start:
|
||||
start += 1
|
||||
continue
|
||||
|
||||
if isinstance(dialogue, TimePassageMessage):
|
||||
log.debug("build_archive", time_passage_message=dialogue)
|
||||
if i == start:
|
||||
ts = util.iso8601_add(ts, dialogue.ts)
|
||||
log.debug("build_archive", time_passage_message=dialogue, start=start, i=i, ts=ts)
|
||||
start += 1
|
||||
continue
|
||||
log.debug("build_archive", time_passage_message_termination=dialogue)
|
||||
time_passage_termination = True
|
||||
end = i - 1
|
||||
break
|
||||
|
||||
tokens += util.count_tokens(dialogue)
|
||||
dialogue_entries.append(dialogue)
|
||||
if tokens > token_threshold: #
|
||||
@@ -68,49 +100,65 @@ class SummarizeAgent(Agent):
|
||||
if end is None:
|
||||
# nothing to archive yet
|
||||
return
|
||||
|
||||
log.debug("build_archive", start=start, end=end, ts=ts, time_passage_termination=time_passage_termination)
|
||||
|
||||
extra_context = None
|
||||
if recent_entry:
|
||||
extra_context = recent_entry["text"]
|
||||
|
||||
# in order to summarize coherently, we need to determine if there is a favorable
|
||||
# cutoff point (e.g., the scene naturally ends or shifts meaninfully in the middle
|
||||
# of the dialogue)
|
||||
#
|
||||
# One way to do this is to check if the last line is a TimePassageMessage, which
|
||||
# indicates a scene change or a significant pause.
|
||||
#
|
||||
# If not, we can ask the AI to find a good point of
|
||||
# termination.
|
||||
|
||||
if not time_passage_termination:
|
||||
|
||||
# No TimePassageMessage, so we need to ask the AI to find a good point of termination
|
||||
|
||||
terminating_line = await self.analyze_dialoge(dialogue_entries)
|
||||
|
||||
terminating_line = await self.analyze_dialoge(dialogue_entries)
|
||||
if terminating_line:
|
||||
adjusted_dialogue = []
|
||||
for line in dialogue_entries:
|
||||
if str(line) in terminating_line:
|
||||
break
|
||||
adjusted_dialogue.append(line)
|
||||
dialogue_entries = adjusted_dialogue
|
||||
end = start + len(dialogue_entries)
|
||||
|
||||
if dialogue_entries:
|
||||
summarized = await self.summarize(
|
||||
"\n".join(map(str, dialogue_entries)), extra_context=extra_context
|
||||
)
|
||||
|
||||
else:
|
||||
# AI has likely identified the first line as a scene change, so we can't summarize
|
||||
# just use the first line
|
||||
summarized = str(scene.history[start])
|
||||
|
||||
log.debug("summarize agent build archive", terminating_line=terminating_line)
|
||||
# determine the appropariate timestamp for the summarization
|
||||
|
||||
if terminating_line:
|
||||
adjusted_dialogue = []
|
||||
for line in dialogue_entries:
|
||||
if str(line) in terminating_line:
|
||||
break
|
||||
adjusted_dialogue.append(line)
|
||||
dialogue_entries = adjusted_dialogue
|
||||
end = start + len(dialogue_entries)
|
||||
|
||||
summarized = await self.summarize(
|
||||
"\n".join(map(str, dialogue_entries)), extra_context=extra_context
|
||||
)
|
||||
|
||||
scene.push_archive(data_objects.ArchiveEntry(summarized, start, end))
|
||||
scene.push_archive(data_objects.ArchiveEntry(summarized, start, end, ts=ts))
|
||||
|
||||
return True
|
||||
|
||||
@set_processing
|
||||
async def analyze_dialoge(self, dialogue):
|
||||
instruction = "Examine the dialogue from the beginning and find the first line that marks a scene change. Repeat the line back to me exactly as it is written"
|
||||
|
||||
prepare_response = "The first line that marks a scene change is: "
|
||||
|
||||
prompt = dialogue + ["", instruction, f"<|BOT|>{prepare_response}"]
|
||||
|
||||
response = await self.client.send_prompt("\n".join(map(str, prompt)), kind="summarize")
|
||||
|
||||
if prepare_response in response:
|
||||
response = response.replace(prepare_response, "")
|
||||
|
||||
response = await Prompt.request("summarizer.analyze-dialogue", self.client, "analyze_freeform", vars={
|
||||
"dialogue": "\n".join(map(str, dialogue)),
|
||||
"scene": self.scene,
|
||||
"max_tokens": self.client.max_token_length,
|
||||
})
|
||||
|
||||
response = self.clean_result(response)
|
||||
|
||||
return response
|
||||
|
||||
|
||||
@set_processing
|
||||
async def summarize(
|
||||
self,
|
||||
@@ -129,7 +177,7 @@ class SummarizeAgent(Agent):
|
||||
"max_tokens": self.client.max_token_length,
|
||||
})
|
||||
|
||||
self.scene.log.info("summarize", dialogue=text, response=response)
|
||||
self.scene.log.info("summarize", dialogue_length=len(text), summarized_length=len(response))
|
||||
|
||||
return self.clean_result(response)
|
||||
|
||||
@@ -195,4 +243,52 @@ class SummarizeAgent(Agent):
|
||||
|
||||
self.scene.log.debug("request_world_state_inline", marked_items=marked_items_response, time=time.time() - t1)
|
||||
|
||||
return marked_items_response
|
||||
return marked_items_response
|
||||
|
||||
@set_processing
|
||||
async def analyze_time_passage(
|
||||
self,
|
||||
text: str,
|
||||
):
|
||||
|
||||
response = await Prompt.request(
|
||||
"summarizer.analyze-time-passage",
|
||||
self.client,
|
||||
"analyze_freeform_short",
|
||||
vars = {
|
||||
"scene": self.scene,
|
||||
"max_tokens": self.client.max_token_length,
|
||||
"text": text,
|
||||
}
|
||||
)
|
||||
|
||||
duration = response.split("\n")[0].split(" ")[0].strip()
|
||||
|
||||
if not duration.startswith("P"):
|
||||
duration = "P"+duration
|
||||
|
||||
return duration
|
||||
|
||||
|
||||
@set_processing
|
||||
async def analyze_text_and_answer_question(
|
||||
self,
|
||||
text: str,
|
||||
query: str,
|
||||
):
|
||||
|
||||
response = await Prompt.request(
|
||||
"summarizer.analyze-text-and-answer-question",
|
||||
self.client,
|
||||
"analyze_freeform",
|
||||
vars = {
|
||||
"scene": self.scene,
|
||||
"max_tokens": self.client.max_token_length,
|
||||
"text": text,
|
||||
"query": query,
|
||||
}
|
||||
)
|
||||
|
||||
log.debug("analyze_text_and_answer_question", query=query, text=text, response=response)
|
||||
|
||||
return response
|
||||
@@ -4,6 +4,9 @@ Context managers for various client-side operations.
|
||||
|
||||
from contextvars import ContextVar
|
||||
from pydantic import BaseModel, Field
|
||||
from copy import deepcopy
|
||||
|
||||
import structlog
|
||||
|
||||
__all__ = [
|
||||
'context_data',
|
||||
@@ -11,6 +14,14 @@ __all__ = [
|
||||
'ContextModel',
|
||||
]
|
||||
|
||||
log = structlog.get_logger()
|
||||
|
||||
def model_to_dict_without_defaults(model_instance):
|
||||
model_dict = model_instance.dict()
|
||||
for field_name, field in model_instance.__class__.__fields__.items():
|
||||
if field.default == model_dict.get(field_name):
|
||||
del model_dict[field_name]
|
||||
return model_dict
|
||||
|
||||
class ConversationContext(BaseModel):
|
||||
talking_character: str = None
|
||||
@@ -47,33 +58,23 @@ class ClientContext:
|
||||
Initialize the context manager with the key-value pairs to be set.
|
||||
"""
|
||||
# Validate the data with the Pydantic model
|
||||
self.values = ContextModel(**kwargs).dict()
|
||||
self.tokens = {}
|
||||
self.values = model_to_dict_without_defaults(ContextModel(**kwargs))
|
||||
|
||||
def __enter__(self):
|
||||
"""
|
||||
Set the key-value pairs to the context variable `context_data` when entering the context.
|
||||
"""
|
||||
# Get the current context data
|
||||
data = context_data.get()
|
||||
# For each key-value pair, save the current value of the key (if it exists) and set the new value
|
||||
for key, value in self.values.items():
|
||||
self.tokens[key] = data.get(key, None)
|
||||
data[key] = value
|
||||
|
||||
data = deepcopy(context_data.get()) if context_data.get() else {}
|
||||
data.update(self.values)
|
||||
|
||||
# Update the context data
|
||||
context_data.set(data)
|
||||
self.token = context_data.set(data)
|
||||
|
||||
def __exit__(self, exc_type, exc_val, exc_tb):
|
||||
"""
|
||||
Reset the context variable `context_data` to its previous values when exiting the context.
|
||||
"""
|
||||
# Get the current context data
|
||||
data = context_data.get()
|
||||
# For each key, if a previous value exists, reset it. Otherwise, remove the key
|
||||
for key in self.values.keys():
|
||||
if self.tokens[key] is not None:
|
||||
data[key] = self.tokens[key]
|
||||
else:
|
||||
data.pop(key, None)
|
||||
# Update the context data
|
||||
context_data.set(data)
|
||||
|
||||
context_data.reset(self.token)
|
||||
|
||||
@@ -491,10 +491,15 @@ class TextGeneratorWebuiClient(RESTTaleMateClient):
|
||||
"chat_prompt_size": self.max_token_length,
|
||||
}
|
||||
|
||||
config.update(PRESET_SIMPLE_1)
|
||||
config.update(PRESET_LLAMA_PRECISE)
|
||||
return config
|
||||
|
||||
|
||||
def prompt_config_analyze_freeform_short(self, prompt: str) -> dict:
|
||||
config = self.prompt_config_analyze_freeform(prompt)
|
||||
config["max_new_tokens"] = 10
|
||||
return config
|
||||
|
||||
def prompt_config_narrate(self, prompt: str) -> dict:
|
||||
prompt = self.prompt_template(
|
||||
system_prompts.NARRATOR,
|
||||
@@ -606,7 +611,7 @@ class TextGeneratorWebuiClient(RESTTaleMateClient):
|
||||
fn_prompt_config = getattr(self, f"prompt_config_{kind}")
|
||||
fn_url = self.prompt_url
|
||||
message = fn_prompt_config(prompt)
|
||||
|
||||
|
||||
if client_context_attribute("nuke_repetition") > 0.0:
|
||||
log.info("nuke repetition", offset=client_context_attribute("nuke_repetition"), temperature=message["temperature"], repetition_penalty=message["repetition_penalty"])
|
||||
message = jiggle_randomness(message, offset=client_context_attribute("nuke_repetition"))
|
||||
@@ -621,6 +626,8 @@ class TextGeneratorWebuiClient(RESTTaleMateClient):
|
||||
log.debug("send_prompt", token_length=token_length, max_token_length=self.max_token_length)
|
||||
|
||||
message["prompt"] = message["prompt"].strip()
|
||||
|
||||
#print(f"prompt: |{message['prompt']}|")
|
||||
|
||||
response = await self.send_message(message, fn_url())
|
||||
|
||||
|
||||
@@ -22,6 +22,7 @@ from .cmd_save import CmdSave
|
||||
from .cmd_save_as import CmdSaveAs
|
||||
from .cmd_save_characters import CmdSaveCharacters
|
||||
from .cmd_setenv import CmdSetEnvironmentToScene, CmdSetEnvironmentToCreative
|
||||
from .cmd_time_util import *
|
||||
from .cmd_world_state import CmdWorldState
|
||||
from .cmd_run_helios_test import CmdHeliosTest
|
||||
from .manager import Manager
|
||||
@@ -20,8 +20,11 @@ class CmdRebuildArchive(TalemateCommand):
|
||||
if not summarizer:
|
||||
self.system_message("No summarizer found")
|
||||
return True
|
||||
|
||||
self.scene.archived_history = []
|
||||
|
||||
# clear out archived history, but keep pre-established history
|
||||
self.scene.archived_history = [
|
||||
ah for ah in self.scene.archived_history if ah.get("end") is None
|
||||
]
|
||||
|
||||
while True:
|
||||
more = await summarizer.agent.build_archive(self.scene)
|
||||
|
||||
50
src/talemate/commands/cmd_time_util.py
Normal file
50
src/talemate/commands/cmd_time_util.py
Normal file
@@ -0,0 +1,50 @@
|
||||
"""
|
||||
Commands to manage scene timescale
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
from talemate.commands.base import TalemateCommand
|
||||
from talemate.commands.manager import register
|
||||
from talemate.prompts.base import set_default_sectioning_handler
|
||||
from talemate.scene_message import TimePassageMessage
|
||||
from talemate.util import iso8601_duration_to_human
|
||||
from talemate.emit import wait_for_input, emit
|
||||
import isodate
|
||||
|
||||
__all__ = [
|
||||
"CmdAdvanceTime",
|
||||
]
|
||||
|
||||
@register
|
||||
class CmdAdvanceTime(TalemateCommand):
|
||||
"""
|
||||
Command class for the 'advance_time' command
|
||||
"""
|
||||
|
||||
name = "advance_time"
|
||||
description = "Advance the scene time by a given amount (expects iso8601 duration))"
|
||||
aliases = ["time_a"]
|
||||
|
||||
async def run(self):
|
||||
if not self.args:
|
||||
self.emit("system", "You must specify an amount of time to advance")
|
||||
return
|
||||
|
||||
try:
|
||||
isodate.parse_duration(self.args[0])
|
||||
except isodate.ISO8601Error:
|
||||
self.emit("system", "Invalid duration")
|
||||
return
|
||||
|
||||
try:
|
||||
msg = self.args[1]
|
||||
except IndexError:
|
||||
msg = iso8601_duration_to_human(self.args[0], suffix=" later")
|
||||
|
||||
message = TimePassageMessage(ts=self.args[0], message=msg)
|
||||
emit('time', message)
|
||||
|
||||
self.scene.push_history(message)
|
||||
self.scene.emit_status()
|
||||
@@ -1,8 +1,12 @@
|
||||
from dataclasses import dataclass
|
||||
|
||||
__all__ = [
|
||||
"ArchiveEntry",
|
||||
]
|
||||
|
||||
@dataclass
|
||||
class ArchiveEntry:
|
||||
text: str
|
||||
start: int
|
||||
end: int
|
||||
start: int = None
|
||||
end: int = None
|
||||
ts: str = None
|
||||
@@ -29,6 +29,7 @@ class AbortCommand(IOError):
|
||||
class Emission:
|
||||
typ: str
|
||||
message: str = None
|
||||
message_object: SceneMessage = None
|
||||
character: Character = None
|
||||
scene: Scene = None
|
||||
status: str = None
|
||||
@@ -43,12 +44,16 @@ def emit(
|
||||
if typ not in handlers:
|
||||
raise ValueError(f"Unknown message type: {typ}")
|
||||
|
||||
|
||||
if isinstance(message, SceneMessage):
|
||||
kwargs["id"] = message.id
|
||||
message_object = message
|
||||
message = message.message
|
||||
else:
|
||||
message_object = None
|
||||
|
||||
handlers[typ].send(
|
||||
Emission(typ=typ, message=message, character=character, scene=scene, **kwargs)
|
||||
Emission(typ=typ, message=message, character=character, scene=scene, message_object=message_object, **kwargs)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ NarratorMessage = signal("narrator")
|
||||
CharacterMessage = signal("character")
|
||||
PlayerMessage = signal("player")
|
||||
DirectorMessage = signal("director")
|
||||
TimePassageMessage = signal("time")
|
||||
|
||||
ClearScreen = signal("clear_screen")
|
||||
|
||||
@@ -31,6 +32,7 @@ handlers = {
|
||||
"character": CharacterMessage,
|
||||
"player": PlayerMessage,
|
||||
"director": DirectorMessage,
|
||||
"time": TimePassageMessage,
|
||||
"request_input": RequestInput,
|
||||
"receive_input": ReceiveInput,
|
||||
"client_status": ClientStatus,
|
||||
|
||||
@@ -27,6 +27,7 @@ class HistoryEvent(Event):
|
||||
class ArchiveEvent(Event):
|
||||
text: str
|
||||
memory_id: str = None
|
||||
ts: str = None
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -6,7 +6,9 @@ from dotenv import load_dotenv
|
||||
import talemate.events as events
|
||||
from talemate import Actor, Character, Player
|
||||
from talemate.config import load_config
|
||||
from talemate.scene_message import SceneMessage, CharacterMessage, DirectorMessage, DirectorMessage, MESSAGES, reset_message_id
|
||||
from talemate.scene_message import (
|
||||
SceneMessage, CharacterMessage, NarratorMessage, DirectorMessage, MESSAGES, reset_message_id
|
||||
)
|
||||
from talemate.world_state import WorldState
|
||||
import talemate.instance as instance
|
||||
|
||||
@@ -144,12 +146,20 @@ async def load_scene_from_data(
|
||||
)
|
||||
scene.assets.cover_image = scene_data.get("assets", {}).get("cover_image", None)
|
||||
scene.assets.load_assets(scene_data.get("assets", {}).get("assets", {}))
|
||||
|
||||
|
||||
scene.sync_time()
|
||||
log.debug("scene time", ts=scene.ts)
|
||||
|
||||
for ah in scene.archived_history:
|
||||
if reset:
|
||||
break
|
||||
ts = ah.get("ts", "PT1S")
|
||||
|
||||
if not ah.get("ts"):
|
||||
ah["ts"] = ts
|
||||
|
||||
scene.signals["archive_add"].send(
|
||||
events.ArchiveEvent(scene=scene, event_type="archive_add", text=ah["text"])
|
||||
events.ArchiveEvent(scene=scene, event_type="archive_add", text=ah["text"], ts=ts)
|
||||
)
|
||||
|
||||
for character_name, cs in scene.character_states.items():
|
||||
@@ -312,7 +322,7 @@ def _prepare_legacy_history(entry):
|
||||
"""
|
||||
|
||||
if entry.startswith("*"):
|
||||
cls = DirectorMessage
|
||||
cls = NarratorMessage
|
||||
elif entry.startswith("Director instructs"):
|
||||
cls = DirectorMessage
|
||||
else:
|
||||
|
||||
@@ -177,6 +177,9 @@ class Prompt:
|
||||
# prompt variables
|
||||
vars: dict = dataclasses.field(default_factory=dict)
|
||||
|
||||
# pad prepared response and ai response with a white-space
|
||||
pad_prepended_response: bool = True
|
||||
|
||||
prepared_response: str = ""
|
||||
|
||||
eval_response: bool = False
|
||||
@@ -282,6 +285,7 @@ class Prompt:
|
||||
env.globals["set_question_eval"] = self.set_question_eval
|
||||
env.globals["query_scene"] = self.query_scene
|
||||
env.globals["query_memory"] = self.query_memory
|
||||
env.globals["query_text"] = self.query_text
|
||||
env.globals["uuidgen"] = lambda: str(uuid.uuid4())
|
||||
env.globals["to_int"] = lambda x: int(x)
|
||||
env.globals["config"] = self.config
|
||||
@@ -336,7 +340,15 @@ class Prompt:
|
||||
f"Answer: " + loop.run_until_complete(narrator.narrate_query(query, at_the_end=at_the_end, as_narrative=as_narrative)),
|
||||
])
|
||||
|
||||
|
||||
|
||||
def query_text(self, query:str, text:str):
|
||||
loop = asyncio.get_event_loop()
|
||||
summarizer = instance.get_agent("summarizer")
|
||||
query = query.format(**self.vars)
|
||||
return "\n".join([
|
||||
f"Question: {query}",
|
||||
f"Answer: " + loop.run_until_complete(summarizer.analyze_text_and_answer_question(text, query)),
|
||||
])
|
||||
|
||||
def query_memory(self, query:str, as_question_answer:bool=True):
|
||||
loop = asyncio.get_event_loop()
|
||||
@@ -524,7 +536,8 @@ class Prompt:
|
||||
response = await client.send_prompt(str(self), kind=kind)
|
||||
|
||||
if not response.lower().startswith(self.prepared_response.lower()):
|
||||
response = self.prepared_response.rstrip() + " " + response.strip()
|
||||
pad = " " if self.pad_prepended_response else ""
|
||||
response = self.prepared_response.rstrip() + pad + response.strip()
|
||||
|
||||
|
||||
if self.eval_response:
|
||||
|
||||
@@ -7,14 +7,14 @@
|
||||
{% for character in characters -%}
|
||||
{{ character.name }}:
|
||||
{{ character.filtered_sheet(['name', 'description', 'age', 'gender']) }}
|
||||
{{ query_memory(character.name+' personality', as_question_answer= False) }}
|
||||
{{ query_memory("what is "+character.name+"'s personality?", as_question_answer=False) }}
|
||||
|
||||
{% endfor %}
|
||||
<|CLOSE_SECTION|>
|
||||
<|SECTION:DIALOGUE EXAMPLES|>
|
||||
{% for dialogue in talking_character.example_dialogue -%}
|
||||
{{ dialogue }}
|
||||
{% endfor -%}
|
||||
{% for example in talking_character.random_dialogue_examples(num=3) -%}
|
||||
{{ example }}
|
||||
{% endfor %}
|
||||
<|CLOSE_SECTION|>
|
||||
|
||||
<|SECTION:TASK|>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
|
||||
{{ dialogue }}
|
||||
<|SECTION:TASK|>
|
||||
Examine the dialogue from the beginning and find the last line that marks a scene change. Repeat the line back to me exactly as it is written.
|
||||
<|CLOSE_SECTION|>
|
||||
{{ bot_token }}The first line that marks a scene change is:
|
||||
@@ -0,0 +1,7 @@
|
||||
{{ text }}
|
||||
|
||||
<|SECTION:TASK|>
|
||||
Analyze the text above and answer the question.
|
||||
|
||||
Question: {{ query }}
|
||||
{{ bot_token }}Answer:
|
||||
@@ -0,0 +1,5 @@
|
||||
<|SECTION:SCENE|>
|
||||
{{ text }}
|
||||
<|SECTION:TASK|>
|
||||
Question: How much time has passed in the scene above?
|
||||
{{ bot_token }}Answer (ISO8601 duration): P
|
||||
@@ -5,6 +5,9 @@
|
||||
<|SECTION:TASK|>
|
||||
Question: What happens within the dialogue? Summarize into narrative description.
|
||||
Content Context: This is a specific scene from {{ scene.context }}
|
||||
Expected Answer: A summarized narrative description of the dialogue that can be inserted into the ongoing story in place of the dialogue.
|
||||
Expected Answer: A summarized narrative description of the dialogue that can be inserted into the ongoing story in place of the dialogue.
|
||||
|
||||
Include implied time skips (for example characters plan to meet at a later date and then they meet).
|
||||
|
||||
<|CLOSE_SECTION|>
|
||||
Narrator answers:
|
||||
@@ -1,4 +1,5 @@
|
||||
from dataclasses import dataclass, field
|
||||
import isodate
|
||||
|
||||
_message_id = 0
|
||||
|
||||
@@ -11,11 +12,23 @@ def reset_message_id():
|
||||
global _message_id
|
||||
_message_id = 0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SceneMessage:
|
||||
|
||||
"""
|
||||
Base class for all messages that are sent to the scene.
|
||||
"""
|
||||
|
||||
# the mesage itself
|
||||
message: str
|
||||
|
||||
# the id of the message
|
||||
id: int = field(default_factory=get_message_id)
|
||||
|
||||
# the source of the message (e.g. "ai", "progress_story", "director")
|
||||
source: str = ""
|
||||
|
||||
typ = "scene"
|
||||
|
||||
|
||||
@@ -83,12 +96,25 @@ class DirectorMessage(SceneMessage):
|
||||
|
||||
return f"[Story progression instructions for {char_name}: {message}]"
|
||||
|
||||
|
||||
@dataclass
|
||||
class TimePassageMessage(SceneMessage):
|
||||
ts: str = "PT0S"
|
||||
source: str = "manual"
|
||||
typ = "time"
|
||||
|
||||
def __dict__(self):
|
||||
return {
|
||||
"message": self.message,
|
||||
"id": self.id,
|
||||
"typ": "time",
|
||||
"source": self.source,
|
||||
"ts": self.ts,
|
||||
}
|
||||
|
||||
|
||||
MESSAGES = {
|
||||
"scene": SceneMessage,
|
||||
"character": CharacterMessage,
|
||||
"narrator": NarratorMessage,
|
||||
"director": DirectorMessage,
|
||||
"time": TimePassageMessage,
|
||||
}
|
||||
@@ -292,6 +292,16 @@ class WebsocketHandler(Receiver):
|
||||
}
|
||||
)
|
||||
|
||||
def handle_time(self, emission: Emission):
|
||||
self.queue_put(
|
||||
{
|
||||
"type": "time",
|
||||
"message": emission.message,
|
||||
"id": emission.id,
|
||||
"ts": emission.message_object.ts,
|
||||
}
|
||||
)
|
||||
|
||||
def handle_prompt_sent(self, emission: Emission):
|
||||
self.queue_put(
|
||||
{
|
||||
|
||||
@@ -5,6 +5,7 @@ import os
|
||||
import random
|
||||
import traceback
|
||||
import re
|
||||
import isodate
|
||||
from typing import Dict, List, Optional, Union
|
||||
|
||||
from blinker import signal
|
||||
@@ -18,7 +19,7 @@ import talemate.util as util
|
||||
import talemate.save as save
|
||||
from talemate.emit import Emitter, emit, wait_for_input
|
||||
from talemate.util import colored_text, count_tokens, extract_metadata, wrap_text
|
||||
from talemate.scene_message import SceneMessage, CharacterMessage, DirectorMessage, NarratorMessage
|
||||
from talemate.scene_message import SceneMessage, CharacterMessage, DirectorMessage, NarratorMessage, TimePassageMessage
|
||||
from talemate.exceptions import ExitScene, RestartSceneLoop, ResetScene, TalemateError, TalemateInterrupt, LLMAccuracyError
|
||||
from talemate.world_state import WorldState
|
||||
from talemate.config import SceneConfig
|
||||
@@ -141,6 +142,29 @@ class Character:
|
||||
|
||||
return random.choice(self.example_dialogue)
|
||||
|
||||
def random_dialogue_examples(self, num:int=3):
|
||||
"""
|
||||
Get multiple random example dialogue lines for this character.
|
||||
|
||||
Will return up to `num` examples and not have any duplicates.
|
||||
"""
|
||||
|
||||
if not self.example_dialogue:
|
||||
return []
|
||||
|
||||
# create copy of example_dialogue so we dont modify the original
|
||||
|
||||
examples = self.example_dialogue.copy()
|
||||
|
||||
# shuffle the examples so we get a random order
|
||||
|
||||
random.shuffle(examples)
|
||||
|
||||
# now pop examples until we have `num` examples or we run out of examples
|
||||
|
||||
return [examples.pop() for _ in range(min(num, len(examples)))]
|
||||
|
||||
|
||||
def filtered_sheet(self, attributes: list[str]):
|
||||
|
||||
"""
|
||||
@@ -260,10 +284,11 @@ class Character:
|
||||
if "color" in metadata:
|
||||
self.color = metadata["color"]
|
||||
if "mes_example" in metadata:
|
||||
new_line_match = "\r\n" if "\r\n" in metadata["mes_example"] else "\n"
|
||||
for message in metadata["mes_example"].split("<START>"):
|
||||
if message.strip("\r\n"):
|
||||
if message.strip(new_line_match):
|
||||
self.example_dialogue.extend(
|
||||
[m for m in message.split("\r\n") if m]
|
||||
[m for m in message.split(new_line_match) if m]
|
||||
)
|
||||
|
||||
|
||||
@@ -335,9 +360,9 @@ class Character:
|
||||
}
|
||||
})
|
||||
|
||||
for detail in self.details:
|
||||
for key, detail in self.details.items():
|
||||
items.append({
|
||||
"text": f"{self.name} details: {detail}",
|
||||
"text": f"{self.name} - {key}: {detail}",
|
||||
"meta": {
|
||||
"character": self.name,
|
||||
"typ": "details",
|
||||
@@ -522,6 +547,7 @@ class Scene(Emitter):
|
||||
self.environment = "scene"
|
||||
self.goal = None
|
||||
self.world_state = WorldState()
|
||||
self.ts = "PT0S"
|
||||
|
||||
self.automated_actions = {}
|
||||
|
||||
@@ -610,6 +636,10 @@ class Scene(Emitter):
|
||||
|
||||
def push_history(self, messages: list[SceneMessage]):
|
||||
|
||||
"""
|
||||
Adds one or more messages to the scene history
|
||||
"""
|
||||
|
||||
if isinstance(messages, SceneMessage):
|
||||
messages = [messages]
|
||||
|
||||
@@ -623,6 +653,9 @@ class Scene(Emitter):
|
||||
if isinstance(self.history[idx], DirectorMessage):
|
||||
self.history.pop(idx)
|
||||
break
|
||||
|
||||
elif isinstance(message, TimePassageMessage):
|
||||
self.advance_time(message.ts)
|
||||
|
||||
self.history.extend(messages)
|
||||
self.signals["history_add"].send(
|
||||
@@ -634,19 +667,26 @@ class Scene(Emitter):
|
||||
)
|
||||
|
||||
def push_archive(self, entry: data_objects.ArchiveEntry):
|
||||
|
||||
"""
|
||||
Adds an entry to the archive history.
|
||||
|
||||
The archive history is a list of summarized history entries.
|
||||
"""
|
||||
|
||||
self.archived_history.append(entry.__dict__)
|
||||
self.signals["archive_add"].send(
|
||||
events.ArchiveEvent(
|
||||
scene=self,
|
||||
event_type="archive_add",
|
||||
text=entry.text,
|
||||
ts=entry.ts,
|
||||
)
|
||||
)
|
||||
emit("archived_history", data={
|
||||
"history":[archived_history["text"] for archived_history in self.archived_history]
|
||||
})
|
||||
|
||||
|
||||
def edit_message(self, message_id:int, message:str):
|
||||
"""
|
||||
Finds the message in `history` by its id and will update its contents
|
||||
@@ -828,7 +868,7 @@ class Scene(Emitter):
|
||||
# we then take the history from the end index to the end of the history
|
||||
|
||||
if self.archived_history:
|
||||
end = self.archived_history[-1]["end"]
|
||||
end = self.archived_history[-1].get("end", 0)
|
||||
else:
|
||||
end = 0
|
||||
|
||||
@@ -865,8 +905,17 @@ class Scene(Emitter):
|
||||
# description at tbe beginning of the context history
|
||||
|
||||
archive_insert_idx = 0
|
||||
|
||||
# iterate backwards through archived history and count how many entries
|
||||
# there are that have an end index
|
||||
num_archived_entries = 0
|
||||
if add_archieved_history:
|
||||
for i in range(len(self.archived_history) - 1, -1, -1):
|
||||
if self.archived_history[i].get("end") is None:
|
||||
break
|
||||
num_archived_entries += 1
|
||||
|
||||
if len(self.archived_history) <= 2 and add_archieved_history:
|
||||
if num_archived_entries <= 2 and add_archieved_history:
|
||||
|
||||
|
||||
for character in self.characters:
|
||||
@@ -898,6 +947,13 @@ class Scene(Emitter):
|
||||
context_history.insert(archive_insert_idx, "<|CLOSE_SECTION|>")
|
||||
|
||||
while i >= 0 and limit > 0 and add_archieved_history:
|
||||
|
||||
# we skip predefined history, that should be joined in through
|
||||
# long term memory queries
|
||||
|
||||
if self.archived_history[i].get("end") is None:
|
||||
break
|
||||
|
||||
text = self.archived_history[i]["text"]
|
||||
if count_tokens(context_history) + count_tokens(text) > budget:
|
||||
break
|
||||
@@ -1038,6 +1094,11 @@ class Scene(Emitter):
|
||||
self.history.pop(i)
|
||||
log.info(f"Deleted message {message_id}")
|
||||
emit("remove_message", "", id=message_id)
|
||||
|
||||
if isinstance(message, TimePassageMessage):
|
||||
self.sync_time()
|
||||
self.emit_status()
|
||||
|
||||
break
|
||||
|
||||
def emit_status(self):
|
||||
@@ -1050,6 +1111,7 @@ class Scene(Emitter):
|
||||
"scene_config": self.scene_config,
|
||||
"assets": self.assets.dict(),
|
||||
"characters": [actor.character.serialize for actor in self.actors],
|
||||
"scene_time": util.iso8601_duration_to_human(self.ts, suffix="") if self.ts else None,
|
||||
},
|
||||
)
|
||||
|
||||
@@ -1059,7 +1121,58 @@ class Scene(Emitter):
|
||||
"""
|
||||
self.environment = environment
|
||||
self.emit_status()
|
||||
|
||||
|
||||
def advance_time(self, ts: str):
|
||||
"""
|
||||
Accepts an iso6801 duration string and advances the scene's world state by that amount
|
||||
"""
|
||||
|
||||
self.ts = isodate.duration_isoformat(
|
||||
isodate.parse_duration(self.ts) + isodate.parse_duration(ts)
|
||||
)
|
||||
|
||||
def sync_time(self):
|
||||
"""
|
||||
Loops through self.history looking for TimePassageMessage and will
|
||||
advance the world state by the amount of time passed for each
|
||||
"""
|
||||
|
||||
# reset time
|
||||
|
||||
self.ts = "PT0S"
|
||||
|
||||
for message in self.history:
|
||||
if isinstance(message, TimePassageMessage):
|
||||
self.advance_time(message.ts)
|
||||
|
||||
|
||||
self.log.info("sync_time", ts=self.ts)
|
||||
|
||||
# TODO: need to adjust archived_history ts as well
|
||||
# but removal also probably means the history needs to be regenerated
|
||||
# anyway.
|
||||
|
||||
def calc_time(self, start_idx:int=0, end_idx:int=None):
|
||||
"""
|
||||
Loops through self.history looking for TimePassageMessage and will
|
||||
return the sum iso8601 duration string
|
||||
|
||||
Defines start and end indexes
|
||||
"""
|
||||
|
||||
ts = "PT0S"
|
||||
found = False
|
||||
|
||||
for message in self.history[start_idx:end_idx]:
|
||||
if isinstance(message, TimePassageMessage):
|
||||
util.iso8601_add(ts, message.ts)
|
||||
found = True
|
||||
|
||||
if not found:
|
||||
return None
|
||||
|
||||
return ts
|
||||
|
||||
async def start(self):
|
||||
"""
|
||||
Start the scene
|
||||
@@ -1117,7 +1230,7 @@ class Scene(Emitter):
|
||||
actor = self.get_character(char_name).actor
|
||||
except AttributeError:
|
||||
# If the character is not an actor, then it is the narrator
|
||||
self.narrator_message(item)
|
||||
emit(item.typ, item)
|
||||
continue
|
||||
emit("character", item, character=actor.character)
|
||||
if not actor.character.is_player:
|
||||
@@ -1249,6 +1362,7 @@ class Scene(Emitter):
|
||||
"context": scene.context,
|
||||
"world_state": scene.world_state.dict(),
|
||||
"assets": scene.assets.dict(),
|
||||
"ts": scene.ts,
|
||||
}
|
||||
|
||||
emit("system", "Saving scene data to: " + filepath)
|
||||
|
||||
@@ -4,6 +4,8 @@ import json
|
||||
import re
|
||||
import textwrap
|
||||
import structlog
|
||||
import isodate
|
||||
import datetime
|
||||
from typing import List
|
||||
|
||||
from colorama import Back, Fore, Style, init
|
||||
@@ -297,6 +299,26 @@ def pronouns(gender: str) -> tuple[str, str]:
|
||||
return (pronoun, possessive_determiner)
|
||||
|
||||
|
||||
def strip_partial_sentences(text:str) -> str:
|
||||
# Sentence ending characters
|
||||
sentence_endings = ['.', '!', '?', '"', "*"]
|
||||
|
||||
# Check if the last character is already a sentence ending
|
||||
if text[-1] in sentence_endings:
|
||||
return text
|
||||
|
||||
# Split the text into words
|
||||
words = text.split()
|
||||
|
||||
# Iterate over the words in reverse order until a sentence ending is found
|
||||
for i in range(len(words) - 1, -1, -1):
|
||||
if words[i][-1] in sentence_endings:
|
||||
return ' '.join(words[:i+1])
|
||||
|
||||
# If no sentence ending is found, return the original text
|
||||
return text
|
||||
|
||||
|
||||
def clean_paragraph(paragraph: str) -> str:
|
||||
"""
|
||||
Cleans up a paragraph of text by:
|
||||
@@ -431,4 +453,145 @@ def fix_faulty_json(data: str) -> str:
|
||||
data = re.sub(r',\s*}', '}', data)
|
||||
data = re.sub(r',\s*]', ']', data)
|
||||
|
||||
return data
|
||||
return data
|
||||
|
||||
def duration_to_timedelta(duration):
|
||||
"""Convert an isodate.Duration object to a datetime.timedelta object."""
|
||||
days = int(duration.years) * 365 + int(duration.months) * 30 + int(duration.days)
|
||||
return datetime.timedelta(days=days)
|
||||
|
||||
def timedelta_to_duration(delta):
|
||||
"""Convert a datetime.timedelta object to an isodate.Duration object."""
|
||||
days = delta.days
|
||||
years = days // 365
|
||||
days %= 365
|
||||
months = days // 30
|
||||
days %= 30
|
||||
return isodate.duration.Duration(years=years, months=months, days=days)
|
||||
|
||||
def parse_duration_to_isodate_duration(duration_str):
|
||||
"""Parse ISO 8601 duration string and ensure the result is an isodate.Duration."""
|
||||
parsed_duration = isodate.parse_duration(duration_str)
|
||||
if isinstance(parsed_duration, datetime.timedelta):
|
||||
days = parsed_duration.days
|
||||
years = days // 365
|
||||
days %= 365
|
||||
months = days // 30
|
||||
days %= 30
|
||||
return isodate.duration.Duration(years=years, months=months, days=days)
|
||||
return parsed_duration
|
||||
|
||||
def iso8601_diff(duration_str1, duration_str2):
|
||||
# Parse the ISO 8601 duration strings ensuring they are isodate.Duration objects
|
||||
duration1 = parse_duration_to_isodate_duration(duration_str1)
|
||||
duration2 = parse_duration_to_isodate_duration(duration_str2)
|
||||
|
||||
# Convert to timedelta
|
||||
timedelta1 = duration_to_timedelta(duration1)
|
||||
timedelta2 = duration_to_timedelta(duration2)
|
||||
|
||||
# Calculate the difference
|
||||
difference_timedelta = abs(timedelta1 - timedelta2)
|
||||
|
||||
# Convert back to Duration for further processing
|
||||
difference = timedelta_to_duration(difference_timedelta)
|
||||
|
||||
return difference
|
||||
|
||||
def iso8601_duration_to_human(iso_duration, suffix:str=" ago"):
|
||||
# Parse the ISO8601 duration string into an isodate duration object
|
||||
|
||||
if isinstance(iso_duration, isodate.Duration):
|
||||
duration = iso_duration
|
||||
else:
|
||||
duration = isodate.parse_duration(iso_duration)
|
||||
|
||||
if isinstance(duration, isodate.Duration):
|
||||
years = duration.years
|
||||
months = duration.months
|
||||
days = duration.days
|
||||
seconds = duration.tdelta.total_seconds()
|
||||
else:
|
||||
years, months = 0, 0
|
||||
days = duration.days
|
||||
seconds = duration.total_seconds() - days * 86400 # Extract time-only part
|
||||
|
||||
hours, seconds = divmod(seconds, 3600)
|
||||
minutes, seconds = divmod(seconds, 60)
|
||||
|
||||
components = []
|
||||
if years:
|
||||
components.append(f"{years} Year{'s' if years > 1 else ''}")
|
||||
if months:
|
||||
components.append(f"{months} Month{'s' if months > 1 else ''}")
|
||||
if days:
|
||||
components.append(f"{days} Day{'s' if days > 1 else ''}")
|
||||
if hours:
|
||||
components.append(f"{int(hours)} Hour{'s' if hours > 1 else ''}")
|
||||
if minutes:
|
||||
components.append(f"{int(minutes)} Minute{'s' if minutes > 1 else ''}")
|
||||
if seconds:
|
||||
components.append(f"{int(seconds)} Second{'s' if seconds > 1 else ''}")
|
||||
|
||||
# Construct the human-readable string
|
||||
if len(components) > 1:
|
||||
last = components.pop()
|
||||
human_str = ', '.join(components) + ' and ' + last
|
||||
elif components:
|
||||
human_str = components[0]
|
||||
else:
|
||||
human_str = "0 Seconds"
|
||||
|
||||
return f"{human_str}{suffix}"
|
||||
|
||||
def iso8601_diff_to_human(start, end):
|
||||
if not start or not end:
|
||||
return ""
|
||||
|
||||
diff = iso8601_diff(start, end)
|
||||
return iso8601_duration_to_human(diff)
|
||||
|
||||
|
||||
def iso8601_add(date_a:str, date_b:str) -> str:
|
||||
"""
|
||||
Adds two ISO 8601 durations together.
|
||||
"""
|
||||
# Validate input
|
||||
if not date_a or not date_b:
|
||||
return "PT0S"
|
||||
|
||||
new_ts = isodate.parse_duration(date_a.strip()) + isodate.parse_duration(date_b.strip())
|
||||
return isodate.duration_isoformat(new_ts)
|
||||
|
||||
def iso8601_correct_duration(duration: str) -> str:
|
||||
# Split the string into date and time components using 'T' as the delimiter
|
||||
parts = duration.split("T")
|
||||
|
||||
# Handle the date component
|
||||
date_component = parts[0]
|
||||
time_component = ""
|
||||
|
||||
# If there's a time component, process it
|
||||
if len(parts) > 1:
|
||||
time_component = parts[1]
|
||||
|
||||
# Check if the time component has any date values (Y, M, D) and move them to the date component
|
||||
for char in "YD": # Removed 'M' from this loop
|
||||
if char in time_component:
|
||||
index = time_component.index(char)
|
||||
date_component += time_component[:index+1]
|
||||
time_component = time_component[index+1:]
|
||||
|
||||
# If the date component contains any time values (H, M, S), move them to the time component
|
||||
for char in "HMS":
|
||||
if char in date_component:
|
||||
index = date_component.index(char)
|
||||
time_component = date_component[index:] + time_component
|
||||
date_component = date_component[:index]
|
||||
|
||||
# Combine the corrected date and time components
|
||||
corrected_duration = date_component
|
||||
if time_component:
|
||||
corrected_duration += "T" + time_component
|
||||
|
||||
return corrected_duration
|
||||
@@ -40,6 +40,11 @@
|
||||
<DirectorMessage :text="message.text" :message_id="message.id" :character="message.character" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else-if="message.type === 'time'" :class="`message ${message.type}`">
|
||||
<div class="time-message" :id="`message-${message.id}`">
|
||||
<TimePassageMessage :text="message.text" :message_id="message.id" :ts="message.ts" />
|
||||
</div>
|
||||
</div>
|
||||
<div v-else :class="`message ${message.type}`">
|
||||
{{ message.text }}
|
||||
</div>
|
||||
@@ -51,6 +56,7 @@
|
||||
import CharacterMessage from './CharacterMessage.vue';
|
||||
import NarratorMessage from './NarratorMessage.vue';
|
||||
import DirectorMessage from './DirectorMessage.vue';
|
||||
import TimePassageMessage from './TimePassageMessage.vue';
|
||||
|
||||
export default {
|
||||
name: 'SceneMessages',
|
||||
@@ -58,6 +64,7 @@ export default {
|
||||
CharacterMessage,
|
||||
NarratorMessage,
|
||||
DirectorMessage,
|
||||
TimePassageMessage,
|
||||
},
|
||||
data() {
|
||||
return {
|
||||
@@ -87,6 +94,7 @@ export default {
|
||||
multiSelect: data.data.multi_select,
|
||||
color: data.color,
|
||||
sent: false,
|
||||
ts: data.ts,
|
||||
};
|
||||
this.messages.push(message);
|
||||
},
|
||||
@@ -163,10 +171,12 @@ export default {
|
||||
|
||||
if (data.message) {
|
||||
if (data.type === 'character') {
|
||||
const [character, text] = data.message.split(':');
|
||||
const parts = data.message.split(':');
|
||||
const character = parts.shift();
|
||||
const text = parts.join(':');
|
||||
this.messages.push({ id: data.id, type: data.type, character: character.trim(), text: text.trim(), color: data.color }); // Add color property to the message
|
||||
} else if (data.type != 'request_input' && data.type != 'client_status' && data.type != 'agent_status') {
|
||||
this.messages.push({ id: data.id, type: data.type, text: data.message, color: data.color, character: data.character, status:data.status }); // Add color property to the message
|
||||
this.messages.push({ id: data.id, type: data.type, text: data.message, color: data.color, character: data.character, status:data.status, ts:data.ts }); // Add color property to the message
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -86,6 +86,20 @@
|
||||
</v-btn>
|
||||
</template>
|
||||
</v-tooltip>
|
||||
<v-menu>
|
||||
<template v-slot:activator="{ props }">
|
||||
<v-btn class="hotkey mx-3" v-bind="props" :disabled="isInputDisabled()" color="primary" icon>
|
||||
<v-icon>mdi-clock</v-icon>
|
||||
</v-btn>
|
||||
</template>
|
||||
<v-list>
|
||||
<v-list-subheader>Advance Time</v-list-subheader>
|
||||
<v-list-item v-for="(option, index) in advanceTimeOptions" :key="index"
|
||||
@click="sendHotButtonMessage('!advance_time:' + option.value)">
|
||||
<v-list-item-title>{{ option.title }}</v-list-item-title>
|
||||
</v-list-item>
|
||||
</v-list>
|
||||
</v-menu>
|
||||
<v-divider vertical></v-divider>
|
||||
<v-tooltip :disabled="isInputDisabled()" location="top" text="Direct a character">
|
||||
<template v-slot:activator="{ props }">
|
||||
@@ -142,6 +156,7 @@
|
||||
</v-card>
|
||||
|
||||
</div>
|
||||
|
||||
</template>
|
||||
|
||||
|
||||
@@ -154,6 +169,23 @@ export default {
|
||||
return {
|
||||
commandActive: false,
|
||||
commandName: null,
|
||||
|
||||
advanceTimeOptions: [
|
||||
{"value" : "P10Y", "title": "10 years"},
|
||||
{"value" : "P5Y", "title": "5 years"},
|
||||
{"value" : "P1Y", "title": "1 year"},
|
||||
{"value" : "P6M", "title": "6 months"},
|
||||
{"value" : "P3M", "title": "3 months"},
|
||||
{"value" : "P1M", "title": "1 month"},
|
||||
{"value" : "P7D:1 Week later", "title": "1 week"},
|
||||
{"value" : "P3D", "title": "3 days"},
|
||||
{"value" : "P1D", "title": "1 day"},
|
||||
{"value" : "PT8H", "title": "8 hours"},
|
||||
{"value" : "PT4H", "title": "4 hours"},
|
||||
{"Value" : "PT1H", "title": "1 hour"},
|
||||
{"value" : "PT30M", "title": "30 minutes"},
|
||||
{"value" : "PT15M", "title": "15 minutes"}
|
||||
],
|
||||
}
|
||||
},
|
||||
inject: [
|
||||
|
||||
@@ -97,6 +97,11 @@
|
||||
<v-btn v-if="scene.environment === 'scene'" class="ml-1" @click="openSceneHistory()"><v-icon size="14"
|
||||
class="mr-1">mdi-playlist-star</v-icon>History</v-btn>
|
||||
|
||||
<v-chip size="x-small" v-if="scene.scene_time !== undefined">
|
||||
<v-icon>mdi-clock</v-icon>
|
||||
{{ scene.scene_time }}
|
||||
</v-chip>
|
||||
|
||||
</v-toolbar-title>
|
||||
<v-toolbar-title v-else>
|
||||
Talemate
|
||||
@@ -294,6 +299,7 @@ export default {
|
||||
this.scene = {
|
||||
name: data.name,
|
||||
environment: data.data.environment,
|
||||
scene_time: data.data.scene_time,
|
||||
}
|
||||
this.sceneActive = true;
|
||||
return;
|
||||
|
||||
61
talemate_frontend/src/components/TimePassageMessage.vue
Normal file
61
talemate_frontend/src/components/TimePassageMessage.vue
Normal file
@@ -0,0 +1,61 @@
|
||||
<template>
|
||||
<div class="time-container" v-if="show && minimized" >
|
||||
<v-chip closable @click:close="deleteMessage()" color="deep-purple-lighten-3">
|
||||
<v-icon class="mr-2">mdi-clock-outline</v-icon>
|
||||
<span>{{ text }}</span>
|
||||
</v-chip>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script>
|
||||
export default {
|
||||
data() {
|
||||
return {
|
||||
show: true,
|
||||
minimized: true
|
||||
}
|
||||
},
|
||||
props: ['text', 'message_id', 'ts'],
|
||||
inject: ['requestDeleteMessage'],
|
||||
methods: {
|
||||
toggle() {
|
||||
this.minimized = !this.minimized;
|
||||
},
|
||||
deleteMessage() {
|
||||
console.log('deleteMessage', this.message_id);
|
||||
this.requestDeleteMessage(this.message_id);
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.highlight {
|
||||
color: #9FA8DA;
|
||||
font-style: italic;
|
||||
margin-left: 2px;
|
||||
margin-right: 2px;
|
||||
}
|
||||
|
||||
.highlight:before {
|
||||
--content: "*";
|
||||
}
|
||||
|
||||
.highlight:after {
|
||||
--content: "*";
|
||||
}
|
||||
|
||||
.time-text {
|
||||
color: #9FA8DA;
|
||||
}
|
||||
|
||||
.time-message {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
color: #9FA8DA;
|
||||
}
|
||||
|
||||
.time-container {
|
||||
|
||||
}
|
||||
</style>
|
||||
@@ -1,2 +1,2 @@
|
||||
SYSTEM: {{ system_message }}
|
||||
USER: {{ set_response(prompt, "\nASSISTANT:") }}
|
||||
USER: {{ set_response(prompt, "\nASSISTANT: ") }}
|
||||
@@ -0,0 +1,4 @@
|
||||
{{ system_message }}
|
||||
|
||||
### Instruction:
|
||||
{{ set_response(prompt, "\n\n### Response:\n") }}
|
||||
@@ -1,2 +1,2 @@
|
||||
SYSTEM: {{ system_message }}
|
||||
USER: {{ set_response(prompt, "\nASSISTANT:") }}
|
||||
USER: {{ set_response(prompt, "\nASSISTANT: ") }}
|
||||
@@ -1 +1,2 @@
|
||||
{{ system_message }} USER: {{ set_response(prompt, " ASSISTANT:") }}
|
||||
{{ system_message }}
|
||||
USER: {{ set_response(prompt, "\nASSISTANT: ") }}
|
||||
Reference in New Issue
Block a user