space-ace/addons/copilot/Copilot.gd

369 lines
12 KiB
GDScript

@tool
extends Control
@onready var llms = $LLMs
@onready var context_label = $VBoxParent/Context
@onready var status_label = $VBoxParent/Status
@onready var model_select = $VBoxParent/ModelSetting/Model
@onready var shortcut_modifier_select = $VBoxParent/ShortcutSetting/HBoxContainer/Modifier
@onready var shortcut_key_select = $VBoxParent/ShortcutSetting/HBoxContainer/Key
@onready var multiline_toggle = $VBoxParent/MultilineSetting/Multiline
@onready var openai_key_title = $VBoxParent/OpenAiSetting/Label
@onready var openai_key_input = $VBoxParent/OpenAiSetting/OpenAiKey
@onready var version_label = $Version
@onready var info = $VBoxParent/Info
@export var icon_shader : ShaderMaterial
@export var highlight_color : Color
var editor_interface : EditorInterface
var screen = "Script"
var request_code_state = null
var cur_highlight = null
var indicator = null
var models = {}
var openai_api_key
var cur_model
var cur_shortcut_modifier = "Control" if is_mac() else "Alt"
var cur_shortcut_key = "C"
var allow_multiline = true
const PREFERENCES_STORAGE_NAME = "user://copilot.cfg"
const PREFERENCES_PASS = "F4fv2Jxpasp20VS5VSp2Yp2v9aNVJ21aRK"
const GITHUB_COPILOT_DISCLAIMER = "Use GitHub Copilot keys at your own risk. Retrieve key by following instructions [url=https://gitlab.com/aaamoon/copilot-gpt4-service?tab=readme-ov-file#obtaining-copilot-token]here[/url]."
func _ready():
#Initialize dock, load settings
populate_models()
populate_modifiers()
load_config()
func populate_models():
#Add all found models to settings
model_select.clear()
for llm in llms.get_children():
var new_models = llm._get_models()
for model in new_models:
model_select.add_item(model)
models[model] = get_path_to(llm)
model_select.select(0)
set_model(model_select.get_item_text(0))
func populate_modifiers():
#Add available shortcut modifiers based on platform
shortcut_modifier_select.clear()
var modifiers = ["Alt", "Ctrl", "Shift"]
if is_mac(): modifiers = ["Cmd", "Option", "Control", "Shift"]
for modifier in modifiers:
shortcut_modifier_select.add_item(modifier)
apply_by_value(shortcut_modifier_select, cur_shortcut_modifier)
func _unhandled_key_input(event):
#Handle input
if event is InputEventKey:
if cur_highlight:
#If completion is shown, TAB will accept it
#and the TAB input ignored
if event.keycode == KEY_TAB:
undo_input()
clear_highlights()
#BACKSPACE will remove it
elif event.keycode == KEY_BACKSPACE:
revert_change()
clear_highlights()
#Any other key press will plainly accept it
else:
clear_highlights()
#If shortcut modifier and key are pressed, request completion
if shortcut_key_pressed(event) and shortcut_modifier_pressed(event):
request_completion()
func is_mac():
#Platform check
return OS.get_name() == "macOS"
func shortcut_key_pressed(event):
#Check if selected shortcut key is pressed
var key_string = OS.get_keycode_string(event.keycode)
return key_string == cur_shortcut_key
func shortcut_modifier_pressed(event):
#Check if selected shortcut modifier is pressed
match cur_shortcut_modifier:
"Control":
return event.ctrl_pressed
"Ctrl":
return event.ctrl_pressed
"Alt":
return event.alt_pressed
"Option":
return event.alt_pressed
"Shift":
return event.shift_pressed
"Cmd":
return event.meta_pressed
_:
return false
func clear_highlights():
#Clear all currently highlighted lines
#and reset request status
request_code_state = null
cur_highlight = null
var editor = get_code_editor()
for line in range(editor.get_line_count()):
editor.set_line_background_color(line, Color(0, 0, 0, 0))
func undo_input():
#Undo last input in code editor
var editor = get_code_editor()
editor.undo()
func update_loading_indicator(create = false):
#Make sure loading indicator is placed at caret position
if screen != "Script": return
var editor = get_code_editor()
if !editor: return
var line_height = editor.get_line_height()
if !is_instance_valid(indicator):
if !create: return
indicator = ColorRect.new()
indicator.material = icon_shader
indicator.custom_minimum_size = Vector2(line_height, line_height)
editor.add_child(indicator)
var pos = editor.get_caret_draw_pos()
var pre_post = get_pre_post()
#Caret position returned from Godot is not reliable
#Needs to be adjusted for empty lines
var is_on_empty_line = pre_post[0].right(1) == "\n"
var offset = line_height/2-1 if is_on_empty_line else line_height-1
indicator.position = Vector2(pos.x, pos.y - offset)
editor.editable = false
func remove_loading_indicator():
#Free loading indicator, and return editor to editable state
if is_instance_valid(indicator): indicator.queue_free()
set_status("")
var editor = get_code_editor()
editor.editable = true
func set_status(text):
#Update status label in dock
status_label.text = ""
func insert_completion(content: String, pre, post):
#Overwrite code editor text to insert received completion
var editor = get_code_editor()
var scroll = editor.scroll_vertical
var caret_text = pre + content
var lines_from = pre.split("\n")
var lines_to = caret_text.split("\n")
cur_highlight = [lines_from.size(), lines_to.size()]
editor.set_text(pre + content + post)
editor.set_caret_line(lines_to.size())
editor.set_caret_column(lines_to[-1].length())
editor.scroll_vertical = scroll
editor.update_code_completion_options(false)
func revert_change():
#Revert inserted completion
var code_edit = get_code_editor()
var scroll = code_edit.scroll_vertical
var old_text = request_code_state[0] + request_code_state[1]
var lines_from = request_code_state[0].strip_edges(false, true).split("\n")
code_edit.set_text(old_text)
code_edit.set_caret_line(lines_from.size()-1)
code_edit.set_caret_column(lines_from[-1].length())
code_edit.scroll_vertical = scroll
clear_highlights()
func _process(delta):
#Update visuals and context label
update_highlights()
update_loading_indicator()
update_context()
func update_highlights():
#Make sure highlighted lines persist until explicitely removed
#via key input
if cur_highlight:
var editor = get_code_editor()
for line in range(cur_highlight[0]-1, cur_highlight[1]):
editor.set_line_background_color(line, highlight_color)
func update_context():
#Show currently edited file in dock
var script = get_current_script()
if script: context_label.text = script.resource_path.get_file()
func on_main_screen_changed(_screen):
#Track current editor screen (2D, 3D, Script)
screen = _screen
func get_current_script():
#Get currently edited script
if !editor_interface: return
var script_editor = editor_interface.get_script_editor()
return script_editor.get_current_script()
func get_code_editor():
#Get currently used code editor
#This does not return the shader editor!
if !editor_interface: return
var script_editor = editor_interface.get_script_editor()
var base_editor = script_editor.get_current_editor()
if base_editor:
var code_edit = base_editor.get_base_editor()
return code_edit
return null
func request_completion():
#Get current code and request completion from active model
if request_code_state: return
set_status("Asking %s..." % cur_model)
update_loading_indicator(true)
var pre_post = get_pre_post()
var llm = get_llm()
if !llm: return
llm._send_user_prompt(pre_post[0], pre_post[1])
request_code_state = pre_post
func get_pre_post():
#Split current code based on caret position
var editor = get_code_editor()
var text = editor.get_text()
var pos = Vector2(editor.get_caret_line(), editor.get_caret_column())
var pre = ""
var post = ""
for i in range(pos.x):
pre += editor.get_line(i) + "\n"
pre += editor.get_line(pos.x).substr(0,pos.y)
post += editor.get_line(pos.x).substr(pos.y) + "\n"
for ii in range(pos.x+1, editor.get_line_count()):
post += editor.get_line(ii) + "\n"
return [pre, post]
func get_llm():
#Get currently active llm and set active model
var llm = get_node(models[cur_model])
llm._set_api_key(openai_api_key)
llm._set_model(cur_model)
llm._set_multiline(allow_multiline)
return llm
func matches_request_state(pre, post):
#Check if code passed for completion request matches current code
return request_code_state[0] == pre and request_code_state[1] == post
func set_openai_api_key(key):
#Apply API key
openai_api_key = key
func set_model(model_name):
#Apply selected model
cur_model = model_name
# Handle some special model scenarios
if "github-copilot" in model_name:
openai_key_title.text = "GitHub Copilot API Key"
info.parse_bbcode(GITHUB_COPILOT_DISCLAIMER)
info.show()
else:
openai_key_title.text = "OpenAI API Key"
info.hide()
func set_shortcut_modifier(modifier):
#Apply selected shortcut modifier
cur_shortcut_modifier = modifier
func set_shortcut_key(key):
#Apply selected shortcut key
cur_shortcut_key = key
func set_multiline(active):
#Apply selected multiline setting
allow_multiline = active
func _on_code_completion_received(completion, pre, post):
#Attempt to insert received code completion
remove_loading_indicator()
if matches_request_state(pre, post):
insert_completion(completion, pre, post)
else:
clear_highlights()
func _on_code_completion_error(error):
#Display error
remove_loading_indicator()
clear_highlights()
push_error(error)
func _on_open_ai_key_changed(key):
#Apply setting and store in config file
set_openai_api_key(key)
store_config()
func _on_model_selected(index):
#Apply setting and store in config file
set_model(model_select.get_item_text(index))
store_config()
func _on_shortcut_modifier_selected(index):
#Apply setting and store in config file
set_shortcut_modifier(shortcut_modifier_select.get_item_text(index))
store_config()
func _on_shortcut_key_selected(index):
#Apply setting and store in config file
set_shortcut_key(shortcut_key_select.get_item_text(index))
store_config()
func _on_multiline_toggled(button_pressed):
#Apply setting and store in config file
set_multiline(button_pressed)
store_config()
func store_config():
#Store current setting in config file
var config = ConfigFile.new()
config.set_value("preferences", "model", cur_model)
config.set_value("preferences", "shortcut_modifier", cur_shortcut_modifier)
config.set_value("preferences", "shortcut_key", cur_shortcut_key)
config.set_value("preferences", "allow_multiline", allow_multiline)
config.set_value("keys", "openai", openai_api_key)
config.save_encrypted_pass(PREFERENCES_STORAGE_NAME, PREFERENCES_PASS)
func load_config():
#Retrieve current settings from config file
var config = ConfigFile.new()
var err = config.load_encrypted_pass(PREFERENCES_STORAGE_NAME, PREFERENCES_PASS)
if err != OK: return
cur_model = config.get_value("preferences", "model", cur_model)
apply_by_value(model_select, cur_model)
set_model(model_select.get_item_text(model_select.selected))
cur_shortcut_modifier = config.get_value("preferences", "shortcut_modifier", cur_shortcut_modifier)
apply_by_value(shortcut_modifier_select, cur_shortcut_modifier)
cur_shortcut_key = config.get_value("preferences", "shortcut_key", cur_shortcut_key)
apply_by_value(shortcut_key_select, cur_shortcut_key)
allow_multiline = config.get_value("preferences", "allow_multiline", allow_multiline)
multiline_toggle.set_pressed_no_signal(allow_multiline)
openai_api_key = config.get_value("keys", "openai", "")
openai_key_input.text = openai_api_key
func apply_by_value(option_button, value):
#Select item for option button based on value instead of index
for i in option_button.item_count:
if option_button.get_item_text(i) == value:
option_button.select(i)
func set_version(version):
version_label.text = "v%s" % version
func on_info_meta_clicked(meta):
OS.shell_open(meta)