From 0ae4c13ccf38655b0c9971189de8123bceb87d94 Mon Sep 17 00:00:00 2001 From: Vitamin Arrr Date: Sun, 13 Aug 2023 10:55:47 -0700 Subject: [PATCH 01/10] add plugin that generates written content using artificial intelligence via OpenAI API --- docs/gpt.rst | 67 +++++++ gpt.lua | 555 +++++++++++++++++++++++++++++++++++++++++++++++++++ gptserver.py | 106 ++++++++++ 3 files changed, 728 insertions(+) create mode 100644 docs/gpt.rst create mode 100644 gpt.lua create mode 100644 gptserver.py diff --git a/docs/gpt.rst b/docs/gpt.rst new file mode 100644 index 0000000000..30414f9bd0 --- /dev/null +++ b/docs/gpt.rst @@ -0,0 +1,67 @@ +gpt +======= + +.. dfhack-tool:: + :summary: AI-generated written content! + :tags: fort gameplay + +Enables a UI for submitting knowledge item descriptions to OpenAI for generating +poetry, star charts, and excerpts from longer works such as biographies, dictionaries, +treatises on technological evolution, comparative biographies, cultural histories, +autobiographies, cultural comparisons, essays, guides, manuals, and more. + +``enable gpt`` +======= +Enables the plugin. The overlay will be shown when a knowledge item or unit view sheet is open. + +``disable gpt`` +======= +Disables the plugin. + +Setup: + +1. Register for an OpenAI API account. It must be a paid or active trial account. +2. Generate an API token for your account. +3. Save your OpenAI API token to a file at the root of your DF directory, `oaak.txt`. +4. Install python. We used version 3.11 installed from the Microsoft Store. +5. Install python dependencies Flask and OpenAI: `pip install Flask` and `pip install OpenAI`. +6. Start the local helper python app: cd into dfhack/scripts directory & run `python gptserver.py`. + +Once the python helper is running, you may now enable and use the gpt plugin. + +The python script defaults to using the fast, cheap, legacy model `text-davinci-003`. +If you wish to use the slower, more expensive `gpt-3.5-turbo` or `gpt-4` models, you +can start the script with `python gptserver.py -gpt3` or `python gptserver.py -gpt4`. +Tweaking additional OpenAI API parameters will require modifying `gptserver.py` to suit +your particular desires, until such time as someone may have added additional +configuration options in a future update to DFHack :D + +Note: EVERY TEXT YOU GENERATE COSTS $$ if you are on a paid account. The fee is appx. $0.005 USD + at the time of this writing. YMMV! + +Versions of python dependencies tested with: + +Package Version +------------------ --------- +aiohttp 3.8.5 +aiosignal 1.3.1 +async-timeout 4.0.2 +attrs 23.1.0 +blinker 1.6.2 +certifi 2023.7.22 +charset-normalizer 3.2.0 +click 8.1.6 +colorama 0.4.6 +Flask 2.3.2 +frozenlist 1.4.0 +idna 3.4 +itsdangerous 2.1.2 +Jinja2 3.1.2 +MarkupSafe 2.1.3 +multidict 6.0.4 +openai 0.27.8 +requests 2.31.0 +tqdm 4.65.0 +urllib3 2.0.4 +Werkzeug 2.3.6 +yarl 1.9.2 \ No newline at end of file diff --git a/gpt.lua b/gpt.lua new file mode 100644 index 0000000000..7d1da85bd9 --- /dev/null +++ b/gpt.lua @@ -0,0 +1,555 @@ +--@ module = true + +local json = require('json') +local dfhack = require('dfhack') +local utils = require('utils') +local luasocket = require('plugins.luasocket') +local gui = require('gui') +local widgets = require('gui.widgets') +local overlay = require('plugins.overlay') + +-- +-- TYPES +-- + +-- Enum for state of progress of the script. +local Status = { + start = 0, + waiting = 1, + receiving = 2, + done = 3 +} + +local function string_from_Status(status) + if status == Status.start then return "start" end + if status == Status.waiting then return "waiting" end + if status == Status.receiving then return "receiving" end + if status == Status.done then return "done" end +end + +local Content_Type = { + -- Non-fiction + manual = 'manual', + guide = 'guide', + treatise = 'treatise', + essay = 'essay', + dictionary = 'dictionary', + encyclopedia = 'encyclopedia', + star_chart = 'star chart', + -- Literature + poem = 'poem', + short_story = 'short story', + novel = 'novel', + alternate_history = 'alternate history', + -- Individual + letter = 'letter', + autobiography = 'autobiography', + biography = 'biography', + comparative_biography = 'comparative biography', + -- Group + genealogy = 'genealogy', + cultural_history = 'cultural history', + cultural_comparison = 'cultural comparison', + -- Unsupported + unsupported = 'unsupported' +} + +local Progress_Symbol = { '/', '-', '\\', '|' } + +-- +-- CONSTS +-- + +-- Whether or not to print debug outpuut to the console. +local is_debug_output_enabled = false + +-- Port on which to communicate with the python helper. +local port = 5001 + +-- Max number of empty responses from the helper after receiving data before +-- assuming that the response is complete. (Each line is received individually.) +local max_retries = 5 + +-- Whether or not the client object should be configured as blocking. +local is_client_blocking = false + +-- Seconds to configure the client object's timeout. +local client_timeout_secs = 60 + +-- Milliseconds to configure the client object's timeout. +local client_timeout_msecs = 0 + +-- Total client timeout time. +local timeout = client_timeout_secs + client_timeout_msecs/1000 + +-- Number of onRenderFrame events to wait before polling again. +local polling_interval = 10 + +-- Prompt component to use for generating excerpts of non-poetry knowledge items. +local excerpts_prompt = 'Now, imagine two paragraphs, each one taken directly from a different section within the described book. These excerpts should seem like two of the most interesting, insightful, or groundbreaking passages in the treatise. They should read as direct quotes from the text, not as summaries/reviews or quotations of an interview with the author. They should concern minute details of the subject, as an interesting example given by the author, or a colorful anecdote within the text. The two paragraphs should be labeled, Excerpt A and Excerpt B. Two blank newlines should separate the two excerpts cleanly. The text should generally fit in the context of the game, Dwarf Fortress.' +local star_chart_prompt = 'render an ASCII-art Dwarf Fortress star-chart inspired by that description using only Dwarvish names for stellar objects in the legend. DO NOT INCLUDE ANY references to Dwarf Fortress or the process of AI generation, the whole thing must be in-character! The star chart\'s title should match the above description!' + +-- Local config filename. +local config = config or json.open('dfhack-config/gpt.json') + +-- User-facing list of valid content types that the script currently supports. +local valid_content_type_list = (function() + local list = 'a ' + + local size = (function() + local count = 0 + for _ in pairs(Content_Type) do count = count + 1 end + return count + end)() + + local last_supported_index = size - 2 + local index = 0 + + for key, content_type in pairs(Content_Type) do + if key == Content_Type.unsupported then goto continue end + + if index == last_supported_index then + list = list .. 'or ' .. content_type .. '.' + else + list = list .. content_type .. ', ' + end + + index = index + 1 + ::continue:: + end + + return list +end)() + +-- +-- STATE VARS +-- + +-- Tracks the state of the script to manage execution flow. +local current_status = Status.start + +-- Stores a reference to the client object while waiting/receiving a request. +local client = nil + +-- Tracking to maintain polling interval. +local poll_count = 0 + +-- Current number of active retries. +local retries = 0 + +-- Cache for receiving data during polling. +local total_data = '' + +-- When the request was submitted. Used for calculating timeout. +local start_time = nil + +-- Text to display to the user. +local gui_text = "Waiting for knowledge text description..." + +-- The most recently-submitted knowledge item. Used to avoid re-sending +-- the same item multiple times in a row. +local last_knowledge_description = nil + +-- Counter to throttle checks of the UI. +local skip = 0 + +-- +-- FUNCS +-- + +-- Prints `text` to the console if `is_debug_output_enabled` is true. +local function debug_log(text) + if is_debug_output_enabled then print(text) end +end + +-- Saves any configuration data to a JSON file. +local function save_config(data) + utils.assign(config.data, data) + config:write() +end + +-- Observing setter for the `current_status` state var. +local function set_current_status(status) + debug_log('Setting current status from ' .. string_from_Status(current_status) .. ' to ' .. string_from_Status(status)) + current_status = status +end + +-- Determines and returns the Content_Type of a given written content description. +local function content_type_of(knowledge_text, is_knowledge_skill) + for content_type in pairs(Content_Type) do + local search_string = '' .. Content_Type[content_type] + + local knowledge_skill_prefix = 'is a ' + + if content_type == Content_Type.essay or content_type == Content_Type.autobiography then + knowledge_skill_prefix = 'is an ' + end + + if is_knowledge_skill then + search_string = knowledge_skill_prefix .. search_string + end + + if string.find(knowledge_text, search_string) then return content_type + else debug_log('Warning: search string "' .. search_string .. 'not found in knowledge text: "' .. knowledge_text .. '".') end + end + + return Content_Type.unsupported +end + +-- Returns the knowledge item description of the currently-selected in-world object, +-- or nil if the item is not supported. +local function knowledge_item_description() + local view_sheet = df.global.game.main_interface.view_sheets + local knowledge_text = dfhack.df2utf(view_sheet.raw_description) + + if not knowledge_text then + qerror('Error: item description unexpectedly nil. This script may have become out-of-date vs. the released game.') + end + + local current_content_type = content_type_of(knowledge_text, false) + + return knowledge_text, current_content_type +end + +-- Returns the in-game description of the currently selected written content, or nil if none is shown. +-- Also updates the UI to prompt the user for appropriate action. +local function knowledge_description() + local view_sheet = df.global.game.main_interface.view_sheets + + if view_sheet.active_sheet == 1 then + return knowledge_item_description() + end + + local is_knowledge_tab_active = view_sheet.unit_skill_active_tab == 4 + + if not is_knowledge_tab_active then + gui_text = 'Please open the Skills > Knowledge tab.' + return nil + end + + if view_sheet.skill_description_width == 0 then + debug_log('No knowledge item selected yet. Reloading.') + gui_text = 'Please select a ' .. valid_content_type_list .. ' from the list.' + return nil + end + + local knowledge_text = dfhack.df2utf(view_sheet.skill_description_raw_str[0].value) + local if_error_persists = 'Please retry this script. If this error persists, the latest DF update may have broken this script.' + + if not knowledge_text then + qerror(string.concat("Error: Currently selected knowledge item's description is missing or empty. "..if_error_persists)) + end + + local knowledge_prefix_end_index = string.find(knowledge_text, ']') + + if not knowledge_prefix_end_index or string.len(knowledge_text) < knowledge_prefix_end_index then + qerror(string.concat("Error: Currently selected knowledge item's text appears malformed. "..if_error_persists)) + end + + local current_content_type = content_type_of(knowledge_text, true) + + if current_content_type == Content_Type.unsupported then + gui_text = 'This item is not ' .. valid_content_type_list .. ' Please select a valid category to have it generated.' + return nil + end + + local description = string.sub(knowledge_text, knowledge_prefix_end_index + 1) + + return description, current_content_type +end + +-- Generate a prompt from the knowledge_description and content_type supplied. +local function promptFrom(knowledge_description, content_type) + local prompt_value = '' + debug_log('Creating prompt from content_type: ' .. content_type) + + if content_type == Content_Type.poem then + debug_log('Creating poem.') + prompt_value = 'Please write a poem given the following description of the poem and its style: \n\n'..knowledge_description + elseif Content_Type[content_type] == Content_Type.star_chart then + debug_log('Creating star chart.') + prompt_value = 'Considering the star chart description between the >>> <<< below, ' .. star_chart_prompt .. ' >>> ' .. knowledge_description .. ' <<< ' + elseif content_type == Content_Type.unsupported then + debug_log('Creating error response.') + prompt_value = 'Return a response stating simply, "There has been an error."' + else + debug_log('Creating prompt for non-poem/non-star-chart/non-unsupported content_type: ' .. content_type) + prompt_value = 'In between the four carrots is a description of a written ' .. content_type .. ': ^^^^' .. knowledge_description .. '^^^^. \n\n' .. excerpts_prompt + end + + return prompt_value +end + +-- Returns a properly formatted json request to send to +-- the gptserver.py script for submission to OpenAI APIs. +local function request_from(knowledge_description, content_type) + local payload = { + prompt = promptFrom(knowledge_description, content_type) + } + local request = json.encode(payload) + return request +end + +-- Sets up the `client` state var. +local function make_client() + -- Setup client + local client = luasocket.tcp:connect('localhost', port) + if is_client_blocking then client:setBlocking() else client:setNonblocking() end + client:setTimeout(client_timeout_secs,client_timeout_msecs) + return client +end + +-- Tears down the `client` state var and resets the `total_data` and `current_status` state vars. +local function stop_polling(client) + debug_log('Final generated text:' .. gui_text) + set_current_status(Status.done) + debug_log('Done polling. Closing client and processing the response.\n') + client:close() + client = nil + debug_log('Final status: ' .. current_status .. '\n') + total_data = '' + debug_log('Set gui_text to generated text, updating layout...') + set_current_status(Status.start) +end + +-- Swaps out common characters that don't render in DF and converts data to DF's character set. +local function sanitize_response(data) + print(data) + data = string.gsub(data, '“', '"') + data = string.gsub(data, '”', '"') + data = string.gsub(data, '‘', "'") + data = string.gsub(data, '’', "'") + data = string.gsub(data, ' — ', ' -- ') + data = string.gsub(data, ' – ', ' -- ') + data = string.gsub(data, '–', ' -- ') + data = string.gsub(data, '—', ' -- ') + data = dfhack.utf2df(data) + return data +end + +-- Updates a spinning progress indicator while waiting for response from OpenAI API. +local function update_progress_indicator() + assert(current_status == Status.waiting, 'Assertion failure: progress indicator should only be updated while status is waiting. Actual status was: ' .. string_from_Status(current_status)) + local offset = os.difftime(os.time(), start_time) % 4 + local progress_symbol = Progress_Symbol[offset + 1] + gui_text = gui_text:sub(1, gui_text:len() - 2) .. ' ' .. progress_symbol +end + +-- Tries to get the latest data from the client while updating state vars used for +-- tracking progress of polling. +local function poll(client) + if current_status == Status.done or current_status == Status.start then + qerror('Callback tried to poll without being in receiving or waiting status. Status was: ' .. string_from_Status(current_status)) + end + + local data, err = client:receive() + + if err then + qerror("Error from service: " .. err) + end + + if data then + retries = 0 + if current_status == Status.waiting then + set_current_status(Status.receiving) + elseif current_status ~= Status.receiving then + qerror('Error: data received by polling while status was ' .. string_from_Status(current_status)) + end + + local sanitized_data = sanitize_response(data) + + if string.find(data, "Excerpt") then + total_data = total_data .. NEWLINE .. NEWLINE .. sanitized_data + else + total_data = total_data .. NEWLINE .. sanitized_data + end + + gui_text = total_data + else + if current_status == Status.receiving then + if retries >= max_retries then + debug_log("Max retries reached.") + retries = 0 + stop_polling(client) + return + else + retries = retries + 1 + end + elseif current_status == Status.waiting then + update_progress_indicator() + end + end + + if os.difftime(os.time(), start_time) >= timeout then + debug_log('Reached time limit of ' .. timeout .. ', stopping polling.') + retries = 0 + stop_polling(client) + return + end +end + +-- Sends json request to the remote service helper. +local function send(request) + set_current_status(Status.waiting) + client = make_client() + start_time = os.time() + debug_log('Sending request... \n') + client:send(request) + poll(client) +end + +-- Primary entrypoint to the script's functionality. Initiates a check +-- of the UI to see if a supported written content item is being displayed. +-- If so, then submit a request to the remote helper script. +function fetch_generated_text() + skip = skip + 1 + if skip < 20 then return end + skip = 0 + + if current_status ~= Status.start then + debug_log("Current status was not start status, aborting. Status was: " .. string_from_Status(current_status)) + return + end + + local knowledge_description, content_type = knowledge_description() + + if knowledge_description == last_knowledge_description then + return + end + + if not knowledge_description then + debug_log('Poem description became nil, retrying...') + last_knowledge_description = nil + return + end + + if content_type == Content_Type.unsupported then + gui_text = "This content type is not supported. Please select a " .. valid_content_type_list .. "." + last_knowledge_description = nil + return + end + + debug_log('Got new ' .. content_type .. " description: " .. knowledge_description .. "\n") + last_knowledge_description = knowledge_description + gui_text = "Generating text from description, please wait... " + debug_log("Submitting request to OpenAI remote service... \n") + send(request_from(knowledge_description, content_type)) +end + +-- +-- GUI: Overlay +-- + +GPTBannerOverlay = defclass(GPTBannerOverlay, overlay.OverlayWidget) +GPTBannerOverlay.ATTRS{ + default_pos={x=-35,y=-2}, + default_enabled=true, + viewscreens={'dwarfmode/ViewSheets/UNIT','dwarfmode/ViewSheets/ITEM'}, + frame={w=30, h=1}, + frame_background=gui.CLEAR_PEN, +} + +function GPTBannerOverlay:init() + self:addviews{ + widgets.TextButton{ + frame={t=0, l=0}, + label='AI Generation View', + key='CUSTOM_CTRL_G', + on_activate=function() view = view and view:raise() or GPTScreen{}:show() end, + }, + } +end + +function GPTBannerOverlay:onInput(keys) + if GPTBannerOverlay.super.onInput(self, keys) then return true end + + if keys._MOUSE_R_DOWN or keys.LEAVESCREEN then + if view then + view:dismiss() + end + end +end + +OVERLAY_WIDGETS = { + gptbanner=GPTBannerOverlay, +} + +-- +-- GUI: Window +-- + +local default_frame = {w=60, h=30, l=10, t=5} + +GPTWindow = defclass(GPTWindow, widgets.Window) +GPTWindow.ATTRS{ + frame_title='Generated Text', + resize_min=default_frame, + resizable=true, + } + +function GPTWindow:init() + self:addviews{ + widgets.WrappedLabel{ + view_id='label', + frame={t=0, l=0, r=0, b=0}, + auto_height=false, + text_to_wrap=function() return gui_text end, + text_pen=COLOR_YELLOW, + }, + } + self.frame=copyall(config.data.frame or default_frame) +end + +function GPTWindow:onRenderFrame(dc, rect) + GPTWindow.super.onRenderFrame(self, dc, rect) + + if current_status == Status.start then fetch_generated_text() + elseif current_status == Status.done then return + elseif client ~= nil then + if poll_count == polling_interval then + poll(client) + poll_count = 0 + else + poll_count = poll_count + 1 + end + end + + self.subviews.label:updateLayout() +end + +function GPTWindow:postUpdateLayout() + debug_log('saving frame') + save_config({frame = self.frame}) +end + +-- +-- GUI: Screen +-- + +GPTScreen = defclass(GPTScreen, gui.ZScreen) + +GPTScreen.ATTRS { + focus_path='gptscreen', +} + +function GPTScreen:init() + self:addviews{GPTWindow{}} +end + +function GPTScreen:onDismiss() + view = nil +end + +-- +-- Bootstrap +-- + +if dfhack_flags.module then + return +end + +debug_log('Loaded GPT.') +debug_log('valid content types: ' .. valid_content_type_list) + +-- TODO: make into a test +-- debug_log(sanitize_response('"In my pursuit of mastery as a brewer, I stumbled upon an ancient tome hidden amidst a mountain of forgotten manuscripts in the vast library of Keyspirals. Its brittle pages whispered secrets lost to time, revealing a long-forgotten recipe for a peculiar beverage known as \'Dwarven Dream Draught.\' Intrigued by its mystical allure, I dedicated countless hours to deciphering its cryptic instructions. The concoction required rare ingredients, painstakingly procured from the most elusive corners of the world – the crystallized tears of a mountain nymph, the petrified scales of a mythical fire-breathing dragon, and a single drop of moonlight captured on the night of a lunar eclipse. As I blended these exotic components with precision, a magical transformation took place. The resulting elixir possessed an otherworldly glow and an enchanting taste that transcended mortal expectations. This brew became my legacy, forever whispering of the boundless creativity and unwavering dedication of the dwarven race."')) \ No newline at end of file diff --git a/gptserver.py b/gptserver.py new file mode 100644 index 0000000000..354ac54e7d --- /dev/null +++ b/gptserver.py @@ -0,0 +1,106 @@ +import openai +import socket +import json +import signal +import sys + +# Read API key from file +with open("../../oaak.txt", "r") as file: + api_key = file.read().strip() + +print("Proceeding with API key froam oaak.txt.") + +openai.api_key = api_key + +print("Starting server on port 5001... press control-C to exit.") + +serversocket = socket.socket(socket.AF_INET, socket.SOCK_STREAM) +serversocket.bind(("localhost", 5001)) +serversocket.listen(1) + +# Set a timeout for the accept() operation +serversocket.settimeout(1) + +# Define signal handler +def signal_handler(sig, frame): + print('Stopping the server...') + serversocket.close() + sys.exit(0) + +# Register the signal handler +signal.signal(signal.SIGINT, signal_handler) + +model_selection = sys.argv[1] +model_to_use = "text-davinci-003" + +if model_selection == "-gpt3": + model_to_use = "gpt-3.5-turbo" + print("using gpt-3.5-turbo") +elif model_selection == "-gpt4": + model_to_use = "gpt-4" + print("using gpt-4") +elif model_selection == "help" or model_selection == "-help" or model_selection == "--help": + print("`python gptserver.py` defaults to fast, cheap, legacy AI engine `text-davinci-003`") + print("Valid options:") + print(" -gpt3 (uses slower, pricier `gpt-3.5-turbo` model)") + print(" -gpt4 (uses MUCH slower, MUCH pricier `gpt-4` model)") + print("Note: we found gpt4 gave by far the best results!") +elif not model_selection: + print("Defaulting to model: `text-davinci-003`. Use -gpt3 or -gpt4 args for alternates. -help for details!") +else: + print("Invalid argument(s), aborting.") + sys.exit(1) + +while True: + try: + (conn, address) = serversocket.accept() + data = conn.recv(1024*10) + data = data.decode("utf-8") + data = json.loads(data) + + if "prompt" in data: + prompt = data["prompt"] + print("Sending request for prompt: " + prompt) + if model_to_use == "gpt-4" or model_to_use == "gpt-3.5-turbo": + response = openai.ChatCompletion.create( + model=model_to_use, + messages=[ + { + "role": "user", + "content": prompt + } + ], + temperature=1, + max_tokens=3000, + top_p=1, + frequency_penalty=0, + presence_penalty=0 + ) + + response_text = response.choices[0].message.content.strip() + "\n" + print("Got reponse: " + response_text) + conn.sendall(response_text.encode("utf-8")) + conn.close() + + elif model_to_use == "text-davinci-003": + response = openai.Completion.create( + engine="text-davinci-003", + prompt=prompt, + max_tokens=3000 + ) + + response_text = response.choices[0].text.strip() + "\n" + print("Got reponse: " + response_text) + conn.sendall(response_text.encode("utf-8")) + conn.close() + else: + conn.close() + + except socket.timeout: + # In case of timeout, just move on to the next loop iteration + continue + except KeyboardInterrupt: + print("\nInterrupted by keyboard") + break + +serversocket.close() \ No newline at end of file From 6fa6a3af02265188a4c2b1973a8d1708c95c086e Mon Sep 17 00:00:00 2001 From: Vitamin Arrr Date: Sun, 13 Aug 2023 12:30:36 -0700 Subject: [PATCH 02/10] Prevent remote connections --- gptserver.py | 41 ++++++++++++++++++++++++----------------- 1 file changed, 24 insertions(+), 17 deletions(-) diff --git a/gptserver.py b/gptserver.py index 354ac54e7d..a15562ea67 100644 --- a/gptserver.py +++ b/gptserver.py @@ -30,30 +30,37 @@ def signal_handler(sig, frame): # Register the signal handler signal.signal(signal.SIGINT, signal_handler) -model_selection = sys.argv[1] model_to_use = "text-davinci-003" -if model_selection == "-gpt3": - model_to_use = "gpt-3.5-turbo" - print("using gpt-3.5-turbo") -elif model_selection == "-gpt4": - model_to_use = "gpt-4" - print("using gpt-4") -elif model_selection == "help" or model_selection == "-help" or model_selection == "--help": - print("`python gptserver.py` defaults to fast, cheap, legacy AI engine `text-davinci-003`") - print("Valid options:") - print(" -gpt3 (uses slower, pricier `gpt-3.5-turbo` model)") - print(" -gpt4 (uses MUCH slower, MUCH pricier `gpt-4` model)") - print("Note: we found gpt4 gave by far the best results!") -elif not model_selection: +if len(sys.argv) > 1: + model_selection = sys.argv[1] + + if model_selection == "-gpt3": + model_to_use = "gpt-3.5-turbo" + print("using gpt-3.5-turbo") + elif model_selection == "-gpt4": + model_to_use = "gpt-4" + print("using gpt-4") + elif model_selection == "help" or model_selection == "-help" or model_selection == "--help": + print("`python gptserver.py` defaults to fast, cheap, legacy AI engine `text-davinci-003`") + print("Valid options:") + print(" -gpt3 (uses slower, pricier `gpt-3.5-turbo` model)") + print(" -gpt4 (uses MUCH slower, MUCH pricier `gpt-4` model)") + print("Note: we found gpt4 gave by far the best results!") + else: + print("Invalid argument(s), aborting.") + sys.exit(1) +else: print("Defaulting to model: `text-davinci-003`. Use -gpt3 or -gpt4 args for alternates. -help for details!") -else: - print("Invalid argument(s), aborting.") - sys.exit(1) while True: try: (conn, address) = serversocket.accept() + + if address[0] != '127.0.0.1': + print('Attempt to connect from remote address was detected! Closing server. Remote address and NAT port were: ', address) + sys.exit(1) + data = conn.recv(1024*10) data = data.decode("utf-8") data = json.loads(data) From e73719b0ac0ec1e2f8449a4f87f0bbd96e653a45 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Sun, 13 Aug 2023 21:11:18 +0000 Subject: [PATCH 03/10] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- docs/gpt.rst | 14 +- gpt.lua | 1110 +++++++++++++++++++++++++------------------------- gptserver.py | 18 +- 3 files changed, 571 insertions(+), 571 deletions(-) diff --git a/docs/gpt.rst b/docs/gpt.rst index 30414f9bd0..043a3eb433 100644 --- a/docs/gpt.rst +++ b/docs/gpt.rst @@ -6,9 +6,9 @@ gpt :tags: fort gameplay Enables a UI for submitting knowledge item descriptions to OpenAI for generating -poetry, star charts, and excerpts from longer works such as biographies, dictionaries, -treatises on technological evolution, comparative biographies, cultural histories, -autobiographies, cultural comparisons, essays, guides, manuals, and more. +poetry, star charts, and excerpts from longer works such as biographies, dictionaries, +treatises on technological evolution, comparative biographies, cultural histories, +autobiographies, cultural comparisons, essays, guides, manuals, and more. ``enable gpt`` ======= @@ -30,13 +30,13 @@ Setup: Once the python helper is running, you may now enable and use the gpt plugin. The python script defaults to using the fast, cheap, legacy model `text-davinci-003`. -If you wish to use the slower, more expensive `gpt-3.5-turbo` or `gpt-4` models, you +If you wish to use the slower, more expensive `gpt-3.5-turbo` or `gpt-4` models, you can start the script with `python gptserver.py -gpt3` or `python gptserver.py -gpt4`. -Tweaking additional OpenAI API parameters will require modifying `gptserver.py` to suit +Tweaking additional OpenAI API parameters will require modifying `gptserver.py` to suit your particular desires, until such time as someone may have added additional configuration options in a future update to DFHack :D -Note: EVERY TEXT YOU GENERATE COSTS $$ if you are on a paid account. The fee is appx. $0.005 USD +Note: EVERY TEXT YOU GENERATE COSTS $$ if you are on a paid account. The fee is appx. $0.005 USD at the time of this writing. YMMV! Versions of python dependencies tested with: @@ -64,4 +64,4 @@ requests 2.31.0 tqdm 4.65.0 urllib3 2.0.4 Werkzeug 2.3.6 -yarl 1.9.2 \ No newline at end of file +yarl 1.9.2 diff --git a/gpt.lua b/gpt.lua index 7d1da85bd9..1ebd33e3a3 100644 --- a/gpt.lua +++ b/gpt.lua @@ -1,555 +1,555 @@ ---@ module = true - -local json = require('json') -local dfhack = require('dfhack') -local utils = require('utils') -local luasocket = require('plugins.luasocket') -local gui = require('gui') -local widgets = require('gui.widgets') -local overlay = require('plugins.overlay') - --- --- TYPES --- - --- Enum for state of progress of the script. -local Status = { - start = 0, - waiting = 1, - receiving = 2, - done = 3 -} - -local function string_from_Status(status) - if status == Status.start then return "start" end - if status == Status.waiting then return "waiting" end - if status == Status.receiving then return "receiving" end - if status == Status.done then return "done" end -end - -local Content_Type = { - -- Non-fiction - manual = 'manual', - guide = 'guide', - treatise = 'treatise', - essay = 'essay', - dictionary = 'dictionary', - encyclopedia = 'encyclopedia', - star_chart = 'star chart', - -- Literature - poem = 'poem', - short_story = 'short story', - novel = 'novel', - alternate_history = 'alternate history', - -- Individual - letter = 'letter', - autobiography = 'autobiography', - biography = 'biography', - comparative_biography = 'comparative biography', - -- Group - genealogy = 'genealogy', - cultural_history = 'cultural history', - cultural_comparison = 'cultural comparison', - -- Unsupported - unsupported = 'unsupported' -} - -local Progress_Symbol = { '/', '-', '\\', '|' } - --- --- CONSTS --- - --- Whether or not to print debug outpuut to the console. -local is_debug_output_enabled = false - --- Port on which to communicate with the python helper. -local port = 5001 - --- Max number of empty responses from the helper after receiving data before --- assuming that the response is complete. (Each line is received individually.) -local max_retries = 5 - --- Whether or not the client object should be configured as blocking. -local is_client_blocking = false - --- Seconds to configure the client object's timeout. -local client_timeout_secs = 60 - --- Milliseconds to configure the client object's timeout. -local client_timeout_msecs = 0 - --- Total client timeout time. -local timeout = client_timeout_secs + client_timeout_msecs/1000 - --- Number of onRenderFrame events to wait before polling again. -local polling_interval = 10 - --- Prompt component to use for generating excerpts of non-poetry knowledge items. -local excerpts_prompt = 'Now, imagine two paragraphs, each one taken directly from a different section within the described book. These excerpts should seem like two of the most interesting, insightful, or groundbreaking passages in the treatise. They should read as direct quotes from the text, not as summaries/reviews or quotations of an interview with the author. They should concern minute details of the subject, as an interesting example given by the author, or a colorful anecdote within the text. The two paragraphs should be labeled, Excerpt A and Excerpt B. Two blank newlines should separate the two excerpts cleanly. The text should generally fit in the context of the game, Dwarf Fortress.' -local star_chart_prompt = 'render an ASCII-art Dwarf Fortress star-chart inspired by that description using only Dwarvish names for stellar objects in the legend. DO NOT INCLUDE ANY references to Dwarf Fortress or the process of AI generation, the whole thing must be in-character! The star chart\'s title should match the above description!' - --- Local config filename. -local config = config or json.open('dfhack-config/gpt.json') - --- User-facing list of valid content types that the script currently supports. -local valid_content_type_list = (function() - local list = 'a ' - - local size = (function() - local count = 0 - for _ in pairs(Content_Type) do count = count + 1 end - return count - end)() - - local last_supported_index = size - 2 - local index = 0 - - for key, content_type in pairs(Content_Type) do - if key == Content_Type.unsupported then goto continue end - - if index == last_supported_index then - list = list .. 'or ' .. content_type .. '.' - else - list = list .. content_type .. ', ' - end - - index = index + 1 - ::continue:: - end - - return list -end)() - --- --- STATE VARS --- - --- Tracks the state of the script to manage execution flow. -local current_status = Status.start - --- Stores a reference to the client object while waiting/receiving a request. -local client = nil - --- Tracking to maintain polling interval. -local poll_count = 0 - --- Current number of active retries. -local retries = 0 - --- Cache for receiving data during polling. -local total_data = '' - --- When the request was submitted. Used for calculating timeout. -local start_time = nil - --- Text to display to the user. -local gui_text = "Waiting for knowledge text description..." - --- The most recently-submitted knowledge item. Used to avoid re-sending --- the same item multiple times in a row. -local last_knowledge_description = nil - --- Counter to throttle checks of the UI. -local skip = 0 - --- --- FUNCS --- - --- Prints `text` to the console if `is_debug_output_enabled` is true. -local function debug_log(text) - if is_debug_output_enabled then print(text) end -end - --- Saves any configuration data to a JSON file. -local function save_config(data) - utils.assign(config.data, data) - config:write() -end - --- Observing setter for the `current_status` state var. -local function set_current_status(status) - debug_log('Setting current status from ' .. string_from_Status(current_status) .. ' to ' .. string_from_Status(status)) - current_status = status -end - --- Determines and returns the Content_Type of a given written content description. -local function content_type_of(knowledge_text, is_knowledge_skill) - for content_type in pairs(Content_Type) do - local search_string = '' .. Content_Type[content_type] - - local knowledge_skill_prefix = 'is a ' - - if content_type == Content_Type.essay or content_type == Content_Type.autobiography then - knowledge_skill_prefix = 'is an ' - end - - if is_knowledge_skill then - search_string = knowledge_skill_prefix .. search_string - end - - if string.find(knowledge_text, search_string) then return content_type - else debug_log('Warning: search string "' .. search_string .. 'not found in knowledge text: "' .. knowledge_text .. '".') end - end - - return Content_Type.unsupported -end - --- Returns the knowledge item description of the currently-selected in-world object, --- or nil if the item is not supported. -local function knowledge_item_description() - local view_sheet = df.global.game.main_interface.view_sheets - local knowledge_text = dfhack.df2utf(view_sheet.raw_description) - - if not knowledge_text then - qerror('Error: item description unexpectedly nil. This script may have become out-of-date vs. the released game.') - end - - local current_content_type = content_type_of(knowledge_text, false) - - return knowledge_text, current_content_type -end - --- Returns the in-game description of the currently selected written content, or nil if none is shown. --- Also updates the UI to prompt the user for appropriate action. -local function knowledge_description() - local view_sheet = df.global.game.main_interface.view_sheets - - if view_sheet.active_sheet == 1 then - return knowledge_item_description() - end - - local is_knowledge_tab_active = view_sheet.unit_skill_active_tab == 4 - - if not is_knowledge_tab_active then - gui_text = 'Please open the Skills > Knowledge tab.' - return nil - end - - if view_sheet.skill_description_width == 0 then - debug_log('No knowledge item selected yet. Reloading.') - gui_text = 'Please select a ' .. valid_content_type_list .. ' from the list.' - return nil - end - - local knowledge_text = dfhack.df2utf(view_sheet.skill_description_raw_str[0].value) - local if_error_persists = 'Please retry this script. If this error persists, the latest DF update may have broken this script.' - - if not knowledge_text then - qerror(string.concat("Error: Currently selected knowledge item's description is missing or empty. "..if_error_persists)) - end - - local knowledge_prefix_end_index = string.find(knowledge_text, ']') - - if not knowledge_prefix_end_index or string.len(knowledge_text) < knowledge_prefix_end_index then - qerror(string.concat("Error: Currently selected knowledge item's text appears malformed. "..if_error_persists)) - end - - local current_content_type = content_type_of(knowledge_text, true) - - if current_content_type == Content_Type.unsupported then - gui_text = 'This item is not ' .. valid_content_type_list .. ' Please select a valid category to have it generated.' - return nil - end - - local description = string.sub(knowledge_text, knowledge_prefix_end_index + 1) - - return description, current_content_type -end - --- Generate a prompt from the knowledge_description and content_type supplied. -local function promptFrom(knowledge_description, content_type) - local prompt_value = '' - debug_log('Creating prompt from content_type: ' .. content_type) - - if content_type == Content_Type.poem then - debug_log('Creating poem.') - prompt_value = 'Please write a poem given the following description of the poem and its style: \n\n'..knowledge_description - elseif Content_Type[content_type] == Content_Type.star_chart then - debug_log('Creating star chart.') - prompt_value = 'Considering the star chart description between the >>> <<< below, ' .. star_chart_prompt .. ' >>> ' .. knowledge_description .. ' <<< ' - elseif content_type == Content_Type.unsupported then - debug_log('Creating error response.') - prompt_value = 'Return a response stating simply, "There has been an error."' - else - debug_log('Creating prompt for non-poem/non-star-chart/non-unsupported content_type: ' .. content_type) - prompt_value = 'In between the four carrots is a description of a written ' .. content_type .. ': ^^^^' .. knowledge_description .. '^^^^. \n\n' .. excerpts_prompt - end - - return prompt_value -end - --- Returns a properly formatted json request to send to --- the gptserver.py script for submission to OpenAI APIs. -local function request_from(knowledge_description, content_type) - local payload = { - prompt = promptFrom(knowledge_description, content_type) - } - local request = json.encode(payload) - return request -end - --- Sets up the `client` state var. -local function make_client() - -- Setup client - local client = luasocket.tcp:connect('localhost', port) - if is_client_blocking then client:setBlocking() else client:setNonblocking() end - client:setTimeout(client_timeout_secs,client_timeout_msecs) - return client -end - --- Tears down the `client` state var and resets the `total_data` and `current_status` state vars. -local function stop_polling(client) - debug_log('Final generated text:' .. gui_text) - set_current_status(Status.done) - debug_log('Done polling. Closing client and processing the response.\n') - client:close() - client = nil - debug_log('Final status: ' .. current_status .. '\n') - total_data = '' - debug_log('Set gui_text to generated text, updating layout...') - set_current_status(Status.start) -end - --- Swaps out common characters that don't render in DF and converts data to DF's character set. -local function sanitize_response(data) - print(data) - data = string.gsub(data, '“', '"') - data = string.gsub(data, '”', '"') - data = string.gsub(data, '‘', "'") - data = string.gsub(data, '’', "'") - data = string.gsub(data, ' — ', ' -- ') - data = string.gsub(data, ' – ', ' -- ') - data = string.gsub(data, '–', ' -- ') - data = string.gsub(data, '—', ' -- ') - data = dfhack.utf2df(data) - return data -end - --- Updates a spinning progress indicator while waiting for response from OpenAI API. -local function update_progress_indicator() - assert(current_status == Status.waiting, 'Assertion failure: progress indicator should only be updated while status is waiting. Actual status was: ' .. string_from_Status(current_status)) - local offset = os.difftime(os.time(), start_time) % 4 - local progress_symbol = Progress_Symbol[offset + 1] - gui_text = gui_text:sub(1, gui_text:len() - 2) .. ' ' .. progress_symbol -end - --- Tries to get the latest data from the client while updating state vars used for --- tracking progress of polling. -local function poll(client) - if current_status == Status.done or current_status == Status.start then - qerror('Callback tried to poll without being in receiving or waiting status. Status was: ' .. string_from_Status(current_status)) - end - - local data, err = client:receive() - - if err then - qerror("Error from service: " .. err) - end - - if data then - retries = 0 - if current_status == Status.waiting then - set_current_status(Status.receiving) - elseif current_status ~= Status.receiving then - qerror('Error: data received by polling while status was ' .. string_from_Status(current_status)) - end - - local sanitized_data = sanitize_response(data) - - if string.find(data, "Excerpt") then - total_data = total_data .. NEWLINE .. NEWLINE .. sanitized_data - else - total_data = total_data .. NEWLINE .. sanitized_data - end - - gui_text = total_data - else - if current_status == Status.receiving then - if retries >= max_retries then - debug_log("Max retries reached.") - retries = 0 - stop_polling(client) - return - else - retries = retries + 1 - end - elseif current_status == Status.waiting then - update_progress_indicator() - end - end - - if os.difftime(os.time(), start_time) >= timeout then - debug_log('Reached time limit of ' .. timeout .. ', stopping polling.') - retries = 0 - stop_polling(client) - return - end -end - --- Sends json request to the remote service helper. -local function send(request) - set_current_status(Status.waiting) - client = make_client() - start_time = os.time() - debug_log('Sending request... \n') - client:send(request) - poll(client) -end - --- Primary entrypoint to the script's functionality. Initiates a check --- of the UI to see if a supported written content item is being displayed. --- If so, then submit a request to the remote helper script. -function fetch_generated_text() - skip = skip + 1 - if skip < 20 then return end - skip = 0 - - if current_status ~= Status.start then - debug_log("Current status was not start status, aborting. Status was: " .. string_from_Status(current_status)) - return - end - - local knowledge_description, content_type = knowledge_description() - - if knowledge_description == last_knowledge_description then - return - end - - if not knowledge_description then - debug_log('Poem description became nil, retrying...') - last_knowledge_description = nil - return - end - - if content_type == Content_Type.unsupported then - gui_text = "This content type is not supported. Please select a " .. valid_content_type_list .. "." - last_knowledge_description = nil - return - end - - debug_log('Got new ' .. content_type .. " description: " .. knowledge_description .. "\n") - last_knowledge_description = knowledge_description - gui_text = "Generating text from description, please wait... " - debug_log("Submitting request to OpenAI remote service... \n") - send(request_from(knowledge_description, content_type)) -end - --- --- GUI: Overlay --- - -GPTBannerOverlay = defclass(GPTBannerOverlay, overlay.OverlayWidget) -GPTBannerOverlay.ATTRS{ - default_pos={x=-35,y=-2}, - default_enabled=true, - viewscreens={'dwarfmode/ViewSheets/UNIT','dwarfmode/ViewSheets/ITEM'}, - frame={w=30, h=1}, - frame_background=gui.CLEAR_PEN, -} - -function GPTBannerOverlay:init() - self:addviews{ - widgets.TextButton{ - frame={t=0, l=0}, - label='AI Generation View', - key='CUSTOM_CTRL_G', - on_activate=function() view = view and view:raise() or GPTScreen{}:show() end, - }, - } -end - -function GPTBannerOverlay:onInput(keys) - if GPTBannerOverlay.super.onInput(self, keys) then return true end - - if keys._MOUSE_R_DOWN or keys.LEAVESCREEN then - if view then - view:dismiss() - end - end -end - -OVERLAY_WIDGETS = { - gptbanner=GPTBannerOverlay, -} - --- --- GUI: Window --- - -local default_frame = {w=60, h=30, l=10, t=5} - -GPTWindow = defclass(GPTWindow, widgets.Window) -GPTWindow.ATTRS{ - frame_title='Generated Text', - resize_min=default_frame, - resizable=true, - } - -function GPTWindow:init() - self:addviews{ - widgets.WrappedLabel{ - view_id='label', - frame={t=0, l=0, r=0, b=0}, - auto_height=false, - text_to_wrap=function() return gui_text end, - text_pen=COLOR_YELLOW, - }, - } - self.frame=copyall(config.data.frame or default_frame) -end - -function GPTWindow:onRenderFrame(dc, rect) - GPTWindow.super.onRenderFrame(self, dc, rect) - - if current_status == Status.start then fetch_generated_text() - elseif current_status == Status.done then return - elseif client ~= nil then - if poll_count == polling_interval then - poll(client) - poll_count = 0 - else - poll_count = poll_count + 1 - end - end - - self.subviews.label:updateLayout() -end - -function GPTWindow:postUpdateLayout() - debug_log('saving frame') - save_config({frame = self.frame}) -end - --- --- GUI: Screen --- - -GPTScreen = defclass(GPTScreen, gui.ZScreen) - -GPTScreen.ATTRS { - focus_path='gptscreen', -} - -function GPTScreen:init() - self:addviews{GPTWindow{}} -end - -function GPTScreen:onDismiss() - view = nil -end - --- --- Bootstrap --- - -if dfhack_flags.module then - return -end - -debug_log('Loaded GPT.') -debug_log('valid content types: ' .. valid_content_type_list) - --- TODO: make into a test --- debug_log(sanitize_response('"In my pursuit of mastery as a brewer, I stumbled upon an ancient tome hidden amidst a mountain of forgotten manuscripts in the vast library of Keyspirals. Its brittle pages whispered secrets lost to time, revealing a long-forgotten recipe for a peculiar beverage known as \'Dwarven Dream Draught.\' Intrigued by its mystical allure, I dedicated countless hours to deciphering its cryptic instructions. The concoction required rare ingredients, painstakingly procured from the most elusive corners of the world – the crystallized tears of a mountain nymph, the petrified scales of a mythical fire-breathing dragon, and a single drop of moonlight captured on the night of a lunar eclipse. As I blended these exotic components with precision, a magical transformation took place. The resulting elixir possessed an otherworldly glow and an enchanting taste that transcended mortal expectations. This brew became my legacy, forever whispering of the boundless creativity and unwavering dedication of the dwarven race."')) \ No newline at end of file +--@ module = true + +local json = require('json') +local dfhack = require('dfhack') +local utils = require('utils') +local luasocket = require('plugins.luasocket') +local gui = require('gui') +local widgets = require('gui.widgets') +local overlay = require('plugins.overlay') + +-- +-- TYPES +-- + +-- Enum for state of progress of the script. +local Status = { + start = 0, + waiting = 1, + receiving = 2, + done = 3 +} + +local function string_from_Status(status) + if status == Status.start then return "start" end + if status == Status.waiting then return "waiting" end + if status == Status.receiving then return "receiving" end + if status == Status.done then return "done" end +end + +local Content_Type = { + -- Non-fiction + manual = 'manual', + guide = 'guide', + treatise = 'treatise', + essay = 'essay', + dictionary = 'dictionary', + encyclopedia = 'encyclopedia', + star_chart = 'star chart', + -- Literature + poem = 'poem', + short_story = 'short story', + novel = 'novel', + alternate_history = 'alternate history', + -- Individual + letter = 'letter', + autobiography = 'autobiography', + biography = 'biography', + comparative_biography = 'comparative biography', + -- Group + genealogy = 'genealogy', + cultural_history = 'cultural history', + cultural_comparison = 'cultural comparison', + -- Unsupported + unsupported = 'unsupported' +} + +local Progress_Symbol = { '/', '-', '\\', '|' } + +-- +-- CONSTS +-- + +-- Whether or not to print debug outpuut to the console. +local is_debug_output_enabled = false + +-- Port on which to communicate with the python helper. +local port = 5001 + +-- Max number of empty responses from the helper after receiving data before +-- assuming that the response is complete. (Each line is received individually.) +local max_retries = 5 + +-- Whether or not the client object should be configured as blocking. +local is_client_blocking = false + +-- Seconds to configure the client object's timeout. +local client_timeout_secs = 60 + +-- Milliseconds to configure the client object's timeout. +local client_timeout_msecs = 0 + +-- Total client timeout time. +local timeout = client_timeout_secs + client_timeout_msecs/1000 + +-- Number of onRenderFrame events to wait before polling again. +local polling_interval = 10 + +-- Prompt component to use for generating excerpts of non-poetry knowledge items. +local excerpts_prompt = 'Now, imagine two paragraphs, each one taken directly from a different section within the described book. These excerpts should seem like two of the most interesting, insightful, or groundbreaking passages in the treatise. They should read as direct quotes from the text, not as summaries/reviews or quotations of an interview with the author. They should concern minute details of the subject, as an interesting example given by the author, or a colorful anecdote within the text. The two paragraphs should be labeled, Excerpt A and Excerpt B. Two blank newlines should separate the two excerpts cleanly. The text should generally fit in the context of the game, Dwarf Fortress.' +local star_chart_prompt = 'render an ASCII-art Dwarf Fortress star-chart inspired by that description using only Dwarvish names for stellar objects in the legend. DO NOT INCLUDE ANY references to Dwarf Fortress or the process of AI generation, the whole thing must be in-character! The star chart\'s title should match the above description!' + +-- Local config filename. +local config = config or json.open('dfhack-config/gpt.json') + +-- User-facing list of valid content types that the script currently supports. +local valid_content_type_list = (function() + local list = 'a ' + + local size = (function() + local count = 0 + for _ in pairs(Content_Type) do count = count + 1 end + return count + end)() + + local last_supported_index = size - 2 + local index = 0 + + for key, content_type in pairs(Content_Type) do + if key == Content_Type.unsupported then goto continue end + + if index == last_supported_index then + list = list .. 'or ' .. content_type .. '.' + else + list = list .. content_type .. ', ' + end + + index = index + 1 + ::continue:: + end + + return list +end)() + +-- +-- STATE VARS +-- + +-- Tracks the state of the script to manage execution flow. +local current_status = Status.start + +-- Stores a reference to the client object while waiting/receiving a request. +local client = nil + +-- Tracking to maintain polling interval. +local poll_count = 0 + +-- Current number of active retries. +local retries = 0 + +-- Cache for receiving data during polling. +local total_data = '' + +-- When the request was submitted. Used for calculating timeout. +local start_time = nil + +-- Text to display to the user. +local gui_text = "Waiting for knowledge text description..." + +-- The most recently-submitted knowledge item. Used to avoid re-sending +-- the same item multiple times in a row. +local last_knowledge_description = nil + +-- Counter to throttle checks of the UI. +local skip = 0 + +-- +-- FUNCS +-- + +-- Prints `text` to the console if `is_debug_output_enabled` is true. +local function debug_log(text) + if is_debug_output_enabled then print(text) end +end + +-- Saves any configuration data to a JSON file. +local function save_config(data) + utils.assign(config.data, data) + config:write() +end + +-- Observing setter for the `current_status` state var. +local function set_current_status(status) + debug_log('Setting current status from ' .. string_from_Status(current_status) .. ' to ' .. string_from_Status(status)) + current_status = status +end + +-- Determines and returns the Content_Type of a given written content description. +local function content_type_of(knowledge_text, is_knowledge_skill) + for content_type in pairs(Content_Type) do + local search_string = '' .. Content_Type[content_type] + + local knowledge_skill_prefix = 'is a ' + + if content_type == Content_Type.essay or content_type == Content_Type.autobiography then + knowledge_skill_prefix = 'is an ' + end + + if is_knowledge_skill then + search_string = knowledge_skill_prefix .. search_string + end + + if string.find(knowledge_text, search_string) then return content_type + else debug_log('Warning: search string "' .. search_string .. 'not found in knowledge text: "' .. knowledge_text .. '".') end + end + + return Content_Type.unsupported +end + +-- Returns the knowledge item description of the currently-selected in-world object, +-- or nil if the item is not supported. +local function knowledge_item_description() + local view_sheet = df.global.game.main_interface.view_sheets + local knowledge_text = dfhack.df2utf(view_sheet.raw_description) + + if not knowledge_text then + qerror('Error: item description unexpectedly nil. This script may have become out-of-date vs. the released game.') + end + + local current_content_type = content_type_of(knowledge_text, false) + + return knowledge_text, current_content_type +end + +-- Returns the in-game description of the currently selected written content, or nil if none is shown. +-- Also updates the UI to prompt the user for appropriate action. +local function knowledge_description() + local view_sheet = df.global.game.main_interface.view_sheets + + if view_sheet.active_sheet == 1 then + return knowledge_item_description() + end + + local is_knowledge_tab_active = view_sheet.unit_skill_active_tab == 4 + + if not is_knowledge_tab_active then + gui_text = 'Please open the Skills > Knowledge tab.' + return nil + end + + if view_sheet.skill_description_width == 0 then + debug_log('No knowledge item selected yet. Reloading.') + gui_text = 'Please select a ' .. valid_content_type_list .. ' from the list.' + return nil + end + + local knowledge_text = dfhack.df2utf(view_sheet.skill_description_raw_str[0].value) + local if_error_persists = 'Please retry this script. If this error persists, the latest DF update may have broken this script.' + + if not knowledge_text then + qerror(string.concat("Error: Currently selected knowledge item's description is missing or empty. "..if_error_persists)) + end + + local knowledge_prefix_end_index = string.find(knowledge_text, ']') + + if not knowledge_prefix_end_index or string.len(knowledge_text) < knowledge_prefix_end_index then + qerror(string.concat("Error: Currently selected knowledge item's text appears malformed. "..if_error_persists)) + end + + local current_content_type = content_type_of(knowledge_text, true) + + if current_content_type == Content_Type.unsupported then + gui_text = 'This item is not ' .. valid_content_type_list .. ' Please select a valid category to have it generated.' + return nil + end + + local description = string.sub(knowledge_text, knowledge_prefix_end_index + 1) + + return description, current_content_type +end + +-- Generate a prompt from the knowledge_description and content_type supplied. +local function promptFrom(knowledge_description, content_type) + local prompt_value = '' + debug_log('Creating prompt from content_type: ' .. content_type) + + if content_type == Content_Type.poem then + debug_log('Creating poem.') + prompt_value = 'Please write a poem given the following description of the poem and its style: \n\n'..knowledge_description + elseif Content_Type[content_type] == Content_Type.star_chart then + debug_log('Creating star chart.') + prompt_value = 'Considering the star chart description between the >>> <<< below, ' .. star_chart_prompt .. ' >>> ' .. knowledge_description .. ' <<< ' + elseif content_type == Content_Type.unsupported then + debug_log('Creating error response.') + prompt_value = 'Return a response stating simply, "There has been an error."' + else + debug_log('Creating prompt for non-poem/non-star-chart/non-unsupported content_type: ' .. content_type) + prompt_value = 'In between the four carrots is a description of a written ' .. content_type .. ': ^^^^' .. knowledge_description .. '^^^^. \n\n' .. excerpts_prompt + end + + return prompt_value +end + +-- Returns a properly formatted json request to send to +-- the gptserver.py script for submission to OpenAI APIs. +local function request_from(knowledge_description, content_type) + local payload = { + prompt = promptFrom(knowledge_description, content_type) + } + local request = json.encode(payload) + return request +end + +-- Sets up the `client` state var. +local function make_client() + -- Setup client + local client = luasocket.tcp:connect('localhost', port) + if is_client_blocking then client:setBlocking() else client:setNonblocking() end + client:setTimeout(client_timeout_secs,client_timeout_msecs) + return client +end + +-- Tears down the `client` state var and resets the `total_data` and `current_status` state vars. +local function stop_polling(client) + debug_log('Final generated text:' .. gui_text) + set_current_status(Status.done) + debug_log('Done polling. Closing client and processing the response.\n') + client:close() + client = nil + debug_log('Final status: ' .. current_status .. '\n') + total_data = '' + debug_log('Set gui_text to generated text, updating layout...') + set_current_status(Status.start) +end + +-- Swaps out common characters that don't render in DF and converts data to DF's character set. +local function sanitize_response(data) + print(data) + data = string.gsub(data, '“', '"') + data = string.gsub(data, '”', '"') + data = string.gsub(data, '‘', "'") + data = string.gsub(data, '’', "'") + data = string.gsub(data, ' — ', ' -- ') + data = string.gsub(data, ' – ', ' -- ') + data = string.gsub(data, '–', ' -- ') + data = string.gsub(data, '—', ' -- ') + data = dfhack.utf2df(data) + return data +end + +-- Updates a spinning progress indicator while waiting for response from OpenAI API. +local function update_progress_indicator() + assert(current_status == Status.waiting, 'Assertion failure: progress indicator should only be updated while status is waiting. Actual status was: ' .. string_from_Status(current_status)) + local offset = os.difftime(os.time(), start_time) % 4 + local progress_symbol = Progress_Symbol[offset + 1] + gui_text = gui_text:sub(1, gui_text:len() - 2) .. ' ' .. progress_symbol +end + +-- Tries to get the latest data from the client while updating state vars used for +-- tracking progress of polling. +local function poll(client) + if current_status == Status.done or current_status == Status.start then + qerror('Callback tried to poll without being in receiving or waiting status. Status was: ' .. string_from_Status(current_status)) + end + + local data, err = client:receive() + + if err then + qerror("Error from service: " .. err) + end + + if data then + retries = 0 + if current_status == Status.waiting then + set_current_status(Status.receiving) + elseif current_status ~= Status.receiving then + qerror('Error: data received by polling while status was ' .. string_from_Status(current_status)) + end + + local sanitized_data = sanitize_response(data) + + if string.find(data, "Excerpt") then + total_data = total_data .. NEWLINE .. NEWLINE .. sanitized_data + else + total_data = total_data .. NEWLINE .. sanitized_data + end + + gui_text = total_data + else + if current_status == Status.receiving then + if retries >= max_retries then + debug_log("Max retries reached.") + retries = 0 + stop_polling(client) + return + else + retries = retries + 1 + end + elseif current_status == Status.waiting then + update_progress_indicator() + end + end + + if os.difftime(os.time(), start_time) >= timeout then + debug_log('Reached time limit of ' .. timeout .. ', stopping polling.') + retries = 0 + stop_polling(client) + return + end +end + +-- Sends json request to the remote service helper. +local function send(request) + set_current_status(Status.waiting) + client = make_client() + start_time = os.time() + debug_log('Sending request... \n') + client:send(request) + poll(client) +end + +-- Primary entrypoint to the script's functionality. Initiates a check +-- of the UI to see if a supported written content item is being displayed. +-- If so, then submit a request to the remote helper script. +function fetch_generated_text() + skip = skip + 1 + if skip < 20 then return end + skip = 0 + + if current_status ~= Status.start then + debug_log("Current status was not start status, aborting. Status was: " .. string_from_Status(current_status)) + return + end + + local knowledge_description, content_type = knowledge_description() + + if knowledge_description == last_knowledge_description then + return + end + + if not knowledge_description then + debug_log('Poem description became nil, retrying...') + last_knowledge_description = nil + return + end + + if content_type == Content_Type.unsupported then + gui_text = "This content type is not supported. Please select a " .. valid_content_type_list .. "." + last_knowledge_description = nil + return + end + + debug_log('Got new ' .. content_type .. " description: " .. knowledge_description .. "\n") + last_knowledge_description = knowledge_description + gui_text = "Generating text from description, please wait... " + debug_log("Submitting request to OpenAI remote service... \n") + send(request_from(knowledge_description, content_type)) +end + +-- +-- GUI: Overlay +-- + +GPTBannerOverlay = defclass(GPTBannerOverlay, overlay.OverlayWidget) +GPTBannerOverlay.ATTRS{ + default_pos={x=-35,y=-2}, + default_enabled=true, + viewscreens={'dwarfmode/ViewSheets/UNIT','dwarfmode/ViewSheets/ITEM'}, + frame={w=30, h=1}, + frame_background=gui.CLEAR_PEN, +} + +function GPTBannerOverlay:init() + self:addviews{ + widgets.TextButton{ + frame={t=0, l=0}, + label='AI Generation View', + key='CUSTOM_CTRL_G', + on_activate=function() view = view and view:raise() or GPTScreen{}:show() end, + }, + } +end + +function GPTBannerOverlay:onInput(keys) + if GPTBannerOverlay.super.onInput(self, keys) then return true end + + if keys._MOUSE_R_DOWN or keys.LEAVESCREEN then + if view then + view:dismiss() + end + end +end + +OVERLAY_WIDGETS = { + gptbanner=GPTBannerOverlay, +} + +-- +-- GUI: Window +-- + +local default_frame = {w=60, h=30, l=10, t=5} + +GPTWindow = defclass(GPTWindow, widgets.Window) +GPTWindow.ATTRS{ + frame_title='Generated Text', + resize_min=default_frame, + resizable=true, + } + +function GPTWindow:init() + self:addviews{ + widgets.WrappedLabel{ + view_id='label', + frame={t=0, l=0, r=0, b=0}, + auto_height=false, + text_to_wrap=function() return gui_text end, + text_pen=COLOR_YELLOW, + }, + } + self.frame=copyall(config.data.frame or default_frame) +end + +function GPTWindow:onRenderFrame(dc, rect) + GPTWindow.super.onRenderFrame(self, dc, rect) + + if current_status == Status.start then fetch_generated_text() + elseif current_status == Status.done then return + elseif client ~= nil then + if poll_count == polling_interval then + poll(client) + poll_count = 0 + else + poll_count = poll_count + 1 + end + end + + self.subviews.label:updateLayout() +end + +function GPTWindow:postUpdateLayout() + debug_log('saving frame') + save_config({frame = self.frame}) +end + +-- +-- GUI: Screen +-- + +GPTScreen = defclass(GPTScreen, gui.ZScreen) + +GPTScreen.ATTRS { + focus_path='gptscreen', +} + +function GPTScreen:init() + self:addviews{GPTWindow{}} +end + +function GPTScreen:onDismiss() + view = nil +end + +-- +-- Bootstrap +-- + +if dfhack_flags.module then + return +end + +debug_log('Loaded GPT.') +debug_log('valid content types: ' .. valid_content_type_list) + +-- TODO: make into a test +-- debug_log(sanitize_response('"In my pursuit of mastery as a brewer, I stumbled upon an ancient tome hidden amidst a mountain of forgotten manuscripts in the vast library of Keyspirals. Its brittle pages whispered secrets lost to time, revealing a long-forgotten recipe for a peculiar beverage known as \'Dwarven Dream Draught.\' Intrigued by its mystical allure, I dedicated countless hours to deciphering its cryptic instructions. The concoction required rare ingredients, painstakingly procured from the most elusive corners of the world – the crystallized tears of a mountain nymph, the petrified scales of a mythical fire-breathing dragon, and a single drop of moonlight captured on the night of a lunar eclipse. As I blended these exotic components with precision, a magical transformation took place. The resulting elixir possessed an otherworldly glow and an enchanting taste that transcended mortal expectations. This brew became my legacy, forever whispering of the boundless creativity and unwavering dedication of the dwarven race."')) diff --git a/gptserver.py b/gptserver.py index a15562ea67..8a38246596 100644 --- a/gptserver.py +++ b/gptserver.py @@ -40,7 +40,7 @@ def signal_handler(sig, frame): print("using gpt-3.5-turbo") elif model_selection == "-gpt4": model_to_use = "gpt-4" - print("using gpt-4") + print("using gpt-4") elif model_selection == "help" or model_selection == "-help" or model_selection == "--help": print("`python gptserver.py` defaults to fast, cheap, legacy AI engine `text-davinci-003`") print("Valid options:") @@ -50,7 +50,7 @@ def signal_handler(sig, frame): else: print("Invalid argument(s), aborting.") sys.exit(1) -else: +else: print("Defaulting to model: `text-davinci-003`. Use -gpt3 or -gpt4 args for alternates. -help for details!") while True: @@ -60,19 +60,19 @@ def signal_handler(sig, frame): if address[0] != '127.0.0.1': print('Attempt to connect from remote address was detected! Closing server. Remote address and NAT port were: ', address) sys.exit(1) - + data = conn.recv(1024*10) data = data.decode("utf-8") data = json.loads(data) - + if "prompt" in data: prompt = data["prompt"] - print("Sending request for prompt: " + prompt) + print("Sending request for prompt: " + prompt) if model_to_use == "gpt-4" or model_to_use == "gpt-3.5-turbo": response = openai.ChatCompletion.create( model=model_to_use, messages=[ - { + { "role": "user", "content": prompt } @@ -83,7 +83,7 @@ def signal_handler(sig, frame): frequency_penalty=0, presence_penalty=0 ) - + response_text = response.choices[0].message.content.strip() + "\n" print("Got reponse: " + response_text) conn.sendall(response_text.encode("utf-8")) @@ -95,7 +95,7 @@ def signal_handler(sig, frame): prompt=prompt, max_tokens=3000 ) - + response_text = response.choices[0].text.strip() + "\n" print("Got reponse: " + response_text) conn.sendall(response_text.encode("utf-8")) @@ -110,4 +110,4 @@ def signal_handler(sig, frame): print("\nInterrupted by keyboard") break -serversocket.close() \ No newline at end of file +serversocket.close() From b7b86edd4c754e2656cf7c1ed3851a1ce6546846 Mon Sep 17 00:00:00 2001 From: Vitamin Arrr Date: Sun, 13 Aug 2023 20:15:46 -0700 Subject: [PATCH 04/10] update docs, move gptserver under srv directory --- docs/gpt.rst | 18 +++++++++--------- gptserver.py => srv/gptserver.py | 0 2 files changed, 9 insertions(+), 9 deletions(-) rename gptserver.py => srv/gptserver.py (100%) diff --git a/docs/gpt.rst b/docs/gpt.rst index 043a3eb433..7b71aa3d60 100644 --- a/docs/gpt.rst +++ b/docs/gpt.rst @@ -1,5 +1,5 @@ gpt -======= +=== .. dfhack-tool:: :summary: AI-generated written content! @@ -11,11 +11,11 @@ treatises on technological evolution, comparative biographies, cultural historie autobiographies, cultural comparisons, essays, guides, manuals, and more. ``enable gpt`` -======= +============== Enables the plugin. The overlay will be shown when a knowledge item or unit view sheet is open. ``disable gpt`` -======= +=============== Disables the plugin. Setup: @@ -25,19 +25,19 @@ Setup: 3. Save your OpenAI API token to a file at the root of your DF directory, `oaak.txt`. 4. Install python. We used version 3.11 installed from the Microsoft Store. 5. Install python dependencies Flask and OpenAI: `pip install Flask` and `pip install OpenAI`. -6. Start the local helper python app: cd into dfhack/scripts directory & run `python gptserver.py`. +6. Start the local helper python app: cd into dfhack/scripts directory & run `python srv/gptserver.py`. Once the python helper is running, you may now enable and use the gpt plugin. The python script defaults to using the fast, cheap, legacy model `text-davinci-003`. If you wish to use the slower, more expensive `gpt-3.5-turbo` or `gpt-4` models, you -can start the script with `python gptserver.py -gpt3` or `python gptserver.py -gpt4`. +can start the script with `python srv/gptserver.py -gpt3` or `python srv/gptserver.py -gpt4`. Tweaking additional OpenAI API parameters will require modifying `gptserver.py` to suit -your particular desires, until such time as someone may have added additional -configuration options in a future update to DFHack :D +your particular needs. -Note: EVERY TEXT YOU GENERATE COSTS $$ if you are on a paid account. The fee is appx. $0.005 USD - at the time of this writing. YMMV! +Please refer to https://openai.com/pricing for current pricing information. As of Aug. 2023, +the price for a request/response using `-gpt3` mode would be expected to be two to three cents, & +OpenAI offers a free $5 trial API credit for 90 days when you first register. Versions of python dependencies tested with: diff --git a/gptserver.py b/srv/gptserver.py similarity index 100% rename from gptserver.py rename to srv/gptserver.py From de481e0f19bb47d8f10bdf5cf3ff02a7f1706cb2 Mon Sep 17 00:00:00 2001 From: Vitamin Arrr Date: Sun, 13 Aug 2023 20:18:45 -0700 Subject: [PATCH 05/10] Add period to pacify parser --- docs/gpt.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/gpt.rst b/docs/gpt.rst index 7b71aa3d60..e53d669553 100644 --- a/docs/gpt.rst +++ b/docs/gpt.rst @@ -2,7 +2,7 @@ gpt === .. dfhack-tool:: - :summary: AI-generated written content! + :summary: AI-generated written content. :tags: fort gameplay Enables a UI for submitting knowledge item descriptions to OpenAI for generating From 4edbfbb5c747916b83ea591e07e5aad8a1e2197f Mon Sep 17 00:00:00 2001 From: Vitamin Arrr Date: Sun, 13 Aug 2023 20:22:32 -0700 Subject: [PATCH 06/10] PR comments, fixes --- docs/gpt.rst | 2 +- srv/gptserver.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/gpt.rst b/docs/gpt.rst index e53d669553..7b865763bd 100644 --- a/docs/gpt.rst +++ b/docs/gpt.rst @@ -20,7 +20,7 @@ Disables the plugin. Setup: -1. Register for an OpenAI API account. It must be a paid or active trial account. +1. [Register for a free trial or paid OpenAI API account](https://openai.com/product). 2. Generate an API token for your account. 3. Save your OpenAI API token to a file at the root of your DF directory, `oaak.txt`. 4. Install python. We used version 3.11 installed from the Microsoft Store. diff --git a/srv/gptserver.py b/srv/gptserver.py index 8a38246596..07a331c696 100644 --- a/srv/gptserver.py +++ b/srv/gptserver.py @@ -5,7 +5,7 @@ import sys # Read API key from file -with open("../../oaak.txt", "r") as file: +with open("../../../oaak.txt", "r") as file: api_key = file.read().strip() print("Proceeding with API key froam oaak.txt.") From ca5f44ca3741ab81ae117bffc02963357248c25d Mon Sep 17 00:00:00 2001 From: Vitamin Arrr Date: Sun, 13 Aug 2023 21:22:36 -0700 Subject: [PATCH 07/10] pr comments and ensure proper error if server helper is offline --- gpt.lua | 10 ++++++---- srv/gptserver.py | 2 +- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/gpt.lua b/gpt.lua index 1ebd33e3a3..62061c47fa 100644 --- a/gpt.lua +++ b/gpt.lua @@ -390,8 +390,13 @@ end -- Sends json request to the remote service helper. local function send(request) + if not pcall(function() client = make_client() end) then + gui_text = 'Helper server is not running. cd into dfhack/scripts and run `python srv/gptserver.py` to start it. Press escape twice to close the current views, run gptserver.py, then reopen this window to generate the text.' + last_knowledge_description = nil + return + end + set_current_status(Status.waiting) - client = make_client() start_time = os.time() debug_log('Sending request... \n') client:send(request) @@ -550,6 +555,3 @@ end debug_log('Loaded GPT.') debug_log('valid content types: ' .. valid_content_type_list) - --- TODO: make into a test --- debug_log(sanitize_response('"In my pursuit of mastery as a brewer, I stumbled upon an ancient tome hidden amidst a mountain of forgotten manuscripts in the vast library of Keyspirals. Its brittle pages whispered secrets lost to time, revealing a long-forgotten recipe for a peculiar beverage known as \'Dwarven Dream Draught.\' Intrigued by its mystical allure, I dedicated countless hours to deciphering its cryptic instructions. The concoction required rare ingredients, painstakingly procured from the most elusive corners of the world – the crystallized tears of a mountain nymph, the petrified scales of a mythical fire-breathing dragon, and a single drop of moonlight captured on the night of a lunar eclipse. As I blended these exotic components with precision, a magical transformation took place. The resulting elixir possessed an otherworldly glow and an enchanting taste that transcended mortal expectations. This brew became my legacy, forever whispering of the boundless creativity and unwavering dedication of the dwarven race."')) diff --git a/srv/gptserver.py b/srv/gptserver.py index 07a331c696..0d857b0e1d 100644 --- a/srv/gptserver.py +++ b/srv/gptserver.py @@ -5,7 +5,7 @@ import sys # Read API key from file -with open("../../../oaak.txt", "r") as file: +with open("../../../dfhack-config/oaak.txt", "r") as file: api_key = file.read().strip() print("Proceeding with API key froam oaak.txt.") From 9274c6132ae9c02fe6a92de335c689817c5f4a1b Mon Sep 17 00:00:00 2001 From: Vitamin Arrr Date: Sun, 13 Aug 2023 21:27:31 -0700 Subject: [PATCH 08/10] PR comments --- docs/gpt.rst | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/gpt.rst b/docs/gpt.rst index 7b865763bd..8dbcf4c08c 100644 --- a/docs/gpt.rst +++ b/docs/gpt.rst @@ -22,7 +22,7 @@ Setup: 1. [Register for a free trial or paid OpenAI API account](https://openai.com/product). 2. Generate an API token for your account. -3. Save your OpenAI API token to a file at the root of your DF directory, `oaak.txt`. +3. Save your OpenAI API token to a file `oaak.txt` in the dfhack_config directory at the root of your DF directory. 4. Install python. We used version 3.11 installed from the Microsoft Store. 5. Install python dependencies Flask and OpenAI: `pip install Flask` and `pip install OpenAI`. 6. Start the local helper python app: cd into dfhack/scripts directory & run `python srv/gptserver.py`. From b737d2a79a470e08e2652544f30bc5ad1592c4af Mon Sep 17 00:00:00 2001 From: Jon Gilbert Date: Sun, 13 Aug 2023 21:42:03 -0700 Subject: [PATCH 09/10] Apply suggestions from code review Co-authored-by: Myk --- docs/gpt.rst | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/gpt.rst b/docs/gpt.rst index 8dbcf4c08c..d38454915d 100644 --- a/docs/gpt.rst +++ b/docs/gpt.rst @@ -5,10 +5,12 @@ gpt :summary: AI-generated written content. :tags: fort gameplay -Enables a UI for submitting knowledge item descriptions to OpenAI for generating -poetry, star charts, and excerpts from longer works such as biographies, dictionaries, -treatises on technological evolution, comparative biographies, cultural histories, -autobiographies, cultural comparisons, essays, guides, manuals, and more. +When you are inspecting written works, such as poetry, star charts, or treatises on +technological evolution, this tool will generate and display actual examples of written +text based on the in-game description. + +The text is generated by an online AI service, which you have to register for. See below +for required setup. ``enable gpt`` ============== @@ -18,7 +20,8 @@ Enables the plugin. The overlay will be shown when a knowledge item or unit view =============== Disables the plugin. -Setup: +Required online account setup +----------------------------- 1. [Register for a free trial or paid OpenAI API account](https://openai.com/product). 2. Generate an API token for your account. From 0f9a648907b160401c4a7480a49b1e15fab2fff8 Mon Sep 17 00:00:00 2001 From: Vitamin Arrr Date: Tue, 15 Aug 2023 08:23:53 -0700 Subject: [PATCH 10/10] pr comment --- gpt.lua | 1 - 1 file changed, 1 deletion(-) diff --git a/gpt.lua b/gpt.lua index 62061c47fa..1ba8bd724f 100644 --- a/gpt.lua +++ b/gpt.lua @@ -1,7 +1,6 @@ --@ module = true local json = require('json') -local dfhack = require('dfhack') local utils = require('utils') local luasocket = require('plugins.luasocket') local gui = require('gui')