import sys
from PySide6.QtWidgets import QApplication, QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, QLabel, QPushButton, QSizePolicy
from PySide6.QtGui import QPainter, QColor, QFont, QFontMetrics, QPen, QBrush, QMouseEvent, QKeyEvent, QWheelEvent
from PySide6.QtCore import Qt, QRect, QPoint, QEvent, QCoreApplication ,Signal, QThread, QObject
import re
import pyperclip
from langchain_core.callbacks import BaseCallbackHandler
from typing import Any
import threading
import time
import inspect
from tools.exception import InterruptedException as InterruptedException
class PaintLabelBox():
def __init__(self, text=""):
self.rect = QRect(0, 0, 150, 50)
self.font = QFont("Arial", 15)
self.font_name = "Arial" # Store font name
self.font_size = 15 # フォントサイズを保持
self.span = 1
self.text = text
self.align = Qt.AlignLeft
self.height_fit = True
self.border = 5
self.fit_text_lines = []
self.__set_text_rect()
self.__fit_text()
def set_font_size(self, size):
self.font_size = size
self.font = QFont(self.font_name, self.font_size)
self.__fit_text() # フォントサイズ変更後に再計算
def __set_text_rect(self):
if self.rect.width() < ((self.span+self.border) * 2) or self.rect.height() < ((self.span+self.border) * 2): # 最小サイズチェック
return
self.text_rect = QRect(
int(self.rect.x() + self.span+self.border),
int(self.rect.y() + self.span+self.border),
self.rect.width() - (self.span+self.border) * 2,
self.rect.height() - (self.span+self.border) * 2)
def set_hieght_fit(self, fit_flag):
self.height_fit = fit_flag
def set_size(self, width, hight):
if width < ((self.span+self.border) * 2)*2:
width = ((self.span+self.border) * 2)*2
if hight < ((self.span+self.border) * 2)*2:
hight = ((self.span+self.border) * 2)*2
self.rect.setRect(self.rect.x(), self.rect.y(), width, hight)
self.__set_text_rect() # サイズ変更時にtext_rectも更新
self.__fit_text()
def set_width(self, w):
if w < ((self.span+self.border) * 2)*2:
w = ((self.span+self.border) * 2)*2
self.rect.setRect(self.rect.x(), self.rect.y(), w, self.rect.height())
self.__set_text_rect()
self.__fit_text()
def set_position(self, x, y):
self.rect.setRect(x, y, self.rect.width(), self.rect.height())
self.__set_text_rect() # 位置変更時にもtext_rectを更新
self.__fit_text()
def move(self, x, y):
self.rect.setRect(self.rect.x() + x, self.rect.y() + y,
self.rect.width(), self.rect.height())
self.__set_text_rect()
def set_text(self, text):
self.text = text
self.__fit_text()
def get_text(self):
return self.text
def get_last_line_text(self):
if 0 < len(self.fit_text_lines) :
return self.fit_text_lines[-1]
return ""
def append_text(self, text):
self.text += text
#print("DEBUG: text", text)
self.__fit_text_last_line(text)
#self.__fit_text()
def width(self):
return self.rect.width()
def height(self):
return self.rect.height()
def x(self):
return self.rect.x()
def y(self):
return self.rect.y()
# テキストをwidthで改行する
def set_span(self, span):
self.span = span
def set_border(self, bsize):
self.border = bsize
def get_border(self):
return self.border
def __fit_text(self):
if not hasattr(self, 'text_rect') or self.text_rect.width() <= 0: # text_rectが未初期化または不正な場合は何もしない
return
self.fit_text_lines = []
metrics = QFontMetrics(self.font)
lines = self.text.split("\n")
result_text = ""
for line in lines:
text_width = metrics.horizontalAdvance(line)
self.fit_text_lines.append(line)
# 初めの大きさの確認で問題なければ何もしない。
if text_width < self.text_rect.width():
result_text += "\n" + line
continue
result_text += "\n" + self.__add_new_line(metrics, line)
# result_text += self.__add_new_line(metrics, line)
self.fit_text = result_text[1:]
self.__fit_height()
def __add_new_line(self, metrics, line):
if self.text_rect.width() <= 0: # text_rectの幅が不正なら元の行を返す
return line
if len(line) <= 1:
return line
text_width = metrics.horizontalAdvance(line)
num = len(line)
# 分割位置の計算
sep_num = int(self.text_rect.width()/text_width * num)
line_buf = line[:sep_num]
text_width = metrics.horizontalAdvance(line_buf)
pre_text = line_buf
if text_width < self.text_rect.width():
# 分割後のサイズが小さいとき一文字ずつ追加して大きさを超えるところを探す。
while text_width < self.text_rect.width():
pre_text = line_buf
sep_num += 1
line_buf = line[:sep_num]
text_width = metrics.horizontalAdvance(line_buf)
# ここを抜けたという事は行き過ぎなので一つ戻す。
sep_num -= 1
pass
elif self.text_rect.width() < text_width:
# 分割後のサイズが大きいとき一文字減らして追加して大きさを以下になるところを探す。
while self.text_rect.width() < text_width:
sep_num -= 1
line_buf = line[:sep_num]
text_width = metrics.horizontalAdvance(line_buf)
pre_text = line_buf
pass
else:
pre_text = line_buf
# 同じとき完了
self.fit_text_lines[-1] = pre_text
result_text = pre_text
if 0 < sep_num:#文字数が0の時はこの先の処理を行ってはいけない。
# 残った文字列が表示領域より大きいとき再帰的の呼び出す。
line_buf = line[sep_num:]
text_width = metrics.horizontalAdvance(line_buf)
if self.text_rect.width() < text_width:
#print(f"DEBUG: PaintLabelBox.__add_new_line line_buf",metrics, line_buf)
#print(f"DEBUG: PaintLabelBox.__add_new_line text_width",self.text_rect.width(), text_width)
result_text += "\n" + self.__add_new_line(metrics, line_buf)
# result_text += self.__add_new_line(metrics, line_buf)
self.fit_text_lines.append(line_buf)
else:
result_text += "\n" + line_buf
self.fit_text_lines.append(line_buf)
else: # sep_num <= 0 の場合
result_text = line
self.fit_text_lines[-1] = line
return result_text
def __fit_text_last_line(self,append_text):
if not hasattr(self, 'text_rect') or self.text_rect.width() <= 0:# # text_rectが未初期化または不正な場合は何もしない
#print("DEBUG: self.height_fit, text_rect.height", self.height_fit,self.text_rect.height())
return
metrics = QFontMetrics(self.font)
if len(append_text) <= 0:
return
if 0 == len(self.fit_text_lines):
self.fit_text_lines.append("")
line = self.fit_text_lines[-1] + append_text
result_text = ""
text_width = metrics.horizontalAdvance(line)
# 初めの大きさの確認で問題なければ何もしない。
if text_width < self.text_rect.width():
# 開業を付与することでresult_text[1:]との整合性をとる。
# result_text += "\n" + line
result_text += "\n" + line
self.fit_text_lines[-1] = line
else:
# result_text += "\n" + self.__add_new_line(metrics, line)
result_text += "\n" + self.__add_new_line(metrics, line)
# 必ず開業ふぁふよされるので最初の開業を刑したものを付け加える。
#print("result_text", result_text)
self.fit_text += result_text[1:]
self.__fit_height()
def __fit_height(self):
if self.height_fit:
metrics = QFontMetrics(self.font)
# original_height = self.rect.height()
line_height = metrics.boundingRect(self.fit_text).height() # 行の高さ
# line_num = len(self.fit_text.split("\n"))
line_num = len(self.fit_text_lines)
new_height = (line_height+metrics.leading())*line_num + (self.span+self.border)*2
self.rect = QRect(self.rect.x(), self.rect.y(),
self.rect.width(),
new_height)
# final_height = self.rect.height()
# if final_height < original_height:
# if 100 < len(self.text) and final_height < 100:
# print(f"DEBUG WARNING: Height DECREASED in {self.__class__.__name__}.{inspect.currentframe().f_code.co_name}")
# print(f" Original: {original_height}, Final: {final_height}, Diff: {final_height - original_height}")
# print(" Call Stack:")
# for frame_info in inspect.stack():
# print(f" File: {frame_info.filename}, Line: {frame_info.lineno}, Function: {frame_info.function}")
# if frame_info.code_context:
# print(f" Code: {frame_info.code_context[0].strip()}")
self.__set_text_rect()
def set_align(self, pos_st):
if "left" == pos_st.lower():
self.align = Qt.AlignLeft
elif "center" == pos_st.lower():
self.align = Qt.AlignCenter
elif "right" == pos_st.lower():
self.align = Qt.AlignRight
def get_align(self):
return self.align
def draw(self, obj):
painter = QPainter(obj)
painter.setFont(self.font)
if 0 == self.border:
painter.setPen(QPen(QColor(255, 255, 255), self.border)) # 枠線
else:
painter.setPen(QPen(QColor(0, 0, 0), self.border)) # 枠線
painter.setBrush(QBrush(QColor(255, 255, 255))) # 塗りつぶし
painter.drawRect(self.rect)
painter.setPen(QPen(QColor(0, 0, 0), self.border)) # 枠線
painter.drawText(self.text_rect, self.align, self.fit_text)
class PaintButton(PaintLabelBox):
NO_STATE = 0
NOW_CLICKED = 1
NOW_HOVER = 2
def __init__(self, text=""):
super().__init__( text)
self.rect = QRect(50, 50, 100, 50)
self.state = self.NO_STATE
self.set_align("center")
self.nostate_color = QColor(192, 192, 192)
self.clicked_color = QColor(255, 0, 0)
self.hover_color = QColor(224, 224, 224)
def check_point_in(self, pos):
if self.rect.contains(pos):
return True
else:
return False
def mouse_down(self, fpos):
pos = QPoint(fpos.x(),fpos.y())
if self.rect.contains(pos):
self.state = self.NOW_CLICKED
return True
else:
return False
def mouse_up(self, fpos):
pos = QPoint(fpos.x(), fpos.y())
self.state = self.NO_STATE
def mouse_hover(self, fpos):
pos = QPoint(fpos.x(), fpos.y())
if self.rect.contains(pos):
if self.NO_STATE == self.state:
self.state = self.NOW_HOVER
else:
self.state = self.NO_STATE
def set_back_color(self, color):
self.nostate_color = color
def set_clicked_back_color(self, color):
self.clicked_color = color
def set_hover_back_color(self, color):
self.hover_color = color
def draw(self, obj):
painter = QPainter(obj)
if self.NO_STATE == self.state:
painter.setPen(QPen(QColor(0, 0, 0), 2)) # 青色の枠線
painter.setBrush(QBrush(self.nostate_color)) # 赤色の塗りつぶし
if self.NOW_CLICKED == self.state:
painter.setPen(QPen(QColor(0, 0, 0), 2)) # 青色の枠線
painter.setBrush(QBrush(self.clicked_color)) # 赤色の塗りつぶし
if self.NOW_HOVER == self.state:
painter.setPen(QPen(QColor(0, 0, 0), 2)) # 青色の枠線
painter.setBrush(QBrush(self.hover_color)) # 赤色の塗りつぶし
painter.drawRect(self.rect)
painter.setPen(QPen(QColor(0, 0, 0), 2)) # 青色の枠線
painter.drawText(self.rect, self.get_align(), self.text)
class PaintChatTitleBox():
def __init__(self, title_text):
self.button = PaintButton("copy")
self.title = PaintLabelBox(title_text)
def mouse_down(self, pos):
return self.button.mouse_down(pos)
def mouse_up(self, pos):
self.button.mouse_up(pos)
def mouse_hover(self, pos):
self.button.mouse_hover(pos)
def set_title_text(self, text):
self.title.set_text(text)
def get_title_text(self):
self.title.get_text()
def set_font_size(self, size):
if size < 7:
size = 7
self.title.set_font_size(size)
self.button.set_font_size(size-2)
def set_title_size(self, width, height):
self.title.set_size(width, height)
self.button.set_position(self.title.x() + self.title.width() -
self.button.width() - 1,
self.title.y() + 1)
def set_button_size(self, width, height):
self.button.set_size(width, height)
def set_position(self, x, y):
self.title.set_position(x, y)
self.button.set_position(x + self.title.width() -
self.button.width() - 1, y + 1)
def move(self, x, y):
self.title.move(x, y)
self.button.move(x, y)
def height(self):
return self.title.height()
def width(self):
return self.title.width()
def x(self):
return self.title.x()
def y(self):
return self.title.y()
def check_point_in(self, pos):
return self.button.check_point_in(pos)
def draw(self, obj):
self.title.draw(obj)
self.button.draw(obj)
class PaintTitelAndContents():
def __init__(self, title=" ", contents=""):
self.title = PaintChatTitleBox(title)
self.contents = PaintLabelBox(contents)
def mouse_down(self, pos):
return self.title.mouse_down(pos)
def mouse_up(self, pos):
self.title.mouse_up(pos)
def mouse_hover(self, pos):
self.title.mouse_hover(pos)
def set_contents_height_fit(self, fit_flag):
self.contents.set_hieght_fit(fit_flag)
def set_title(self, text):
self.title.set_title_text(text)
def get_title(self):
self.title.get_title_text()
def set_contents(self, text):
self.contents.set_text(text)
def get_contents_text(self):
return self.contents.get_text()
def append_text(self, text):
self.contents.append_text(text)
def set_title_font_size(self, size):
if size < 7:
size = 7
self.title.set_font_size(size)
def set_contents_font_size(self, size):
if size < 7:
size = 7
self.contents.set_font_size(size)
def set_contents_height(self, height):
# original_contents_height = self.contents.height()
self.contents.set_size(self.contents.width(), height)
# final_contents_height = self.contents.height()
# if final_contents_height < original_contents_height:
# if 100 < len(self.contents.get_text()) and final_contents_height < 100:
# print(f"DEBUG WARNING: Contents height DECREASED in {self.__class__.__name__}.{inspect.currentframe().f_code.co_name}")
# print(f" Target: self.contents, Original: {original_contents_height}, Final: {final_contents_height}, Diff: {final_contents_height - original_contents_height}")
# print(f" Called with height: {height}")
# print(" Call Stack:")
# for frame_info in inspect.stack():
# print(f" File: {frame_info.filename}, Line: {frame_info.lineno}, Function: {frame_info.function}")
# if frame_info.code_context:
# print(f" Code: {frame_info.code_context[0].strip()}")
def add_contents_height(self, height):
# original_contents_height = self.contents.height()
self.contents.set_size(self.contents.width(),
self.contents.height() + height)
# final_contents_height = self.contents.height()
# if final_contents_height < original_contents_height: # heightが負の場合など
# if 100 < len(self.contents.get_text()) and final_contents_height < 100:
# print(f"DEBUG WARNING: Contents height DECREASED in {self.__class__.__name__}.{inspect.currentframe().f_code.co_name} after adding {height}")
# print(f" Target: self.contents, Original: {original_contents_height}, Final: {final_contents_height}, Diff: {final_contents_height - original_contents_height}")
# print(" Call Stack:")
# for frame_info in inspect.stack():
# print(f" File: {frame_info.filename}, Line: {frame_info.lineno}, Function: {frame_info.function}")
# if frame_info.code_context:
# print(f" Code: {frame_info.code_context[0].strip()}")
def get_contents_height(self):
return self.contents.height()
def get_title_height(self):
return self.title.height()
def set_width(self, width):
self.title.set_title_size(width, self.title.height())
self.contents.set_size(width, self.contents.height())
self.contents.set_position(self.title.x(),
self.title.y() + self.title.height())
def set_position(self, x, y):
self.title.set_position(x, y)
self.contents.set_position(self.title.x(),
self.title.y() + self.title.height())
def set_contents_border(self, bsize):
self.contents.set_border(bsize)
def get_contents_border(self):
return self.contents.get_border()
def move(self, x, y):
self.title.move(x, y)
self.contents.move(x, y)
def height(self):
return self.title.height() + self.contents.height()
def width(self):
return self.title.width()
def x(self):
return self.title.x()
def y(self):
return self.title.y()
# Add this method to PaintTitelAndContents
def set_font_size_for_all_elements(self, new_size):
self.title.set_font_size(new_size)
self.contents.set_font_size(new_size)
def check_point_in(self, pos):
return self.title.check_point_in(pos)
def draw(self, obj):
self.title.draw(obj)
self.contents.draw(obj)
class PaintChatNode():
NOMAL_BLOCK = 0
PROGRAM_BLOCK = 1
def __init__(self,
title=" ",
contents=" "):
self.main = PaintTitelAndContents(title, "")
self.blok_type = self.NOMAL_BLOCK
self.main.set_contents_height_fit(False)
self.contents_text = ""
self.current_font_size = 15 # Default initial font size for new nodes
self.contents = []
# 差分更新用の状態変数
self._is_streaming = False
self._streaming_buffer = ""
self._streaming_program_name = ""
self._last_content_was_code_title_during_streaming = False # ストリーミング中にコードタイトルが最後だったか
# Apply this default to the main component and its children
self.main.set_font_size_for_all_elements(self.current_font_size)
self.extract_code_block(contents)
def set_font_size_for_all_elements(self, new_size):
self.current_font_size = new_size # Keep track
self.main.set_font_size_for_all_elements(new_size) # Propagate to main PaintTitelAndContents
for content_item in self.contents:
if isinstance(content_item, PaintTitelAndContents):
content_item.set_font_size_for_all_elements(new_size)
elif isinstance(content_item, PaintLabelBox): # Includes PaintButton
content_item.set_font_size(new_size)
self.__fit_height() # Recalculate layout
def mouse_down(self, pos):
if self.main.mouse_down(pos):
pyperclip.copy(self.contents_text)
# print("self.main.get_text()", self.contents_text)
for obj in self.contents:
if PaintTitelAndContents is type(obj):
if obj.mouse_down(pos):
pyperclip.copy(obj.get_contents_text())
# print("obj.get_text()", obj.get_contents_text())
#copy
def mouse_up(self, pos):
self.main.mouse_up(pos)
for obj in self.contents:
if PaintTitelAndContents is type(obj):
obj.mouse_up(pos)
def mouse_hover(self, pos):
self.main.mouse_hover(pos)
def set_title(self, text):
self.main.set_title(text)
def set_contents(self, text):
#self.contents = []
# 差分更新状態をリセット
self._is_streaming = False
self._streaming_buffer = ""
self._streaming_program_name = ""
self.blok_type = self.NOMAL_BLOCK # 全文処理開始時はノーマル
self.extract_code_block(text)
self.main.set_contents(text)
def append_text(self, text_segment):
start_time = time.perf_counter()
# self.main.append_text(text)
# current_full_text = self.main.get_contents_text() # Get the full text after appending
# # Re-extract and re-layout all content blocks based on the new full text
# #self.contents = []
# self.extract_code_block(current_full_text)
self.contents_text += text_segment # 全文を更新
# self.extract_code_block(self.contents_text)
self._extract_and_append_incremental(text_segment) # 差分処理
# self.main の更新は finalize_streaming で行う
self.__fit_height() # 追記の都度、高さ調整
end_time = time.perf_counter()
processing_time = (end_time - start_time) * 1000 # ミリ秒に変換
print(f"DEBUG: PaintChatNode.append_text executed in {processing_time:.3f} ms. Appended text length: {len(text_segment)}")
def set_position(self, x, y):
self.main.set_position(x, y)
for obj in self.contents:
if PaintTitelAndContents is type(obj):
self.__set_titelandcontents_width_and_position(obj)
else:
self.__set_label_width_and_position(obj)
self.__fit_height()
def __set_label_width_and_position(self, label):
label.set_width(self.width() - self.main.get_contents_border()*2)
# x位置の設定 yは後で合わせる
label.set_position(self.x() + self.main.get_contents_border(),
self.y() + self.main.get_contents_border())
def __set_titelandcontents_width_and_position(self, tc):
tc.set_width(self.width() - self.main.get_contents_border()*4)
# x位置の設定 yは後で合わせる
tc.set_position(self.x() + self.main.get_contents_border()*2,
self.y() + self.main.get_contents_border())
def set_width(self, width):
self.main.set_width(width)
for obj in self.contents:
if PaintTitelAndContents is type(obj):
self.__set_titelandcontents_width_and_position(obj)
else:
self.__set_label_width_and_position(obj)
self.__fit_height()
def set_contents_height(self, height):
# original_main_contents_height = self.main.get_contents_height()
self.main.set_contents_height(height)
# final_main_contents_height = self.main.get_contents_height()
# if final_main_contents_height < original_main_contents_height:
# if 100 < len(self.main.contents.get_text()) and final_main_contents_height < 100:
# print(f"DEBUG WARNING: Main contents height DECREASED in {self.__class__.__name__}.{inspect.currentframe().f_code.co_name}")
# print(f" Target: self.main.contents, Original: {original_main_contents_height}, Final: {final_main_contents_height}, Diff: {final_main_contents_height - original_main_contents_height}")
# print(f" Called with height: {height}")
# print(" Call Stack:")
# for frame_info in inspect.stack():
# print(f" File: {frame_info.filename}, Line: {frame_info.lineno}, Function: {frame_info.function}")
# if frame_info.code_context:
# print(f" Code: {frame_info.code_context[0].strip()}")
def height(self):
return self.main.height()
def width(self):
return self.main.width()
def x(self):
return self.main.x()
def y(self):
return self.main.y()
def append_contents_object(self, obj, contents_buf = None):
#print("DEBUG: append_contents_object")
if None is contents_buf:
contents_buf = []
contents_buf.append(obj)
else:
contents_buf.append(obj)
if 0 == len(contents_buf):
obj.set_position(self.main.x(), self.main.get_title_height())
self.main.add_contents_height(obj.height())
def _prepare_content_object(self, obj):
# フォント設定
if hasattr(obj, 'set_font_size_for_all_elements'):
obj.set_font_size_for_all_elements(self.current_font_size)
elif hasattr(obj, 'set_font_size'):
obj.set_font_size(self.current_font_size)
# 幅とX位置設定 (Y位置は __fit_height で調整)
if isinstance(obj, PaintTitelAndContents):
self.__set_titelandcontents_width_and_position(obj)
elif isinstance(obj, PaintLabelBox):
self.__set_label_width_and_position(obj)
return obj
def _extract_and_append_incremental(self, appended_text_segment):
if not self._is_streaming:
self._is_streaming = True
# self.blok_type は直前の状態を引き継ぐ
# _streaming_buffer は空で開始 (finalizeでクリアされるため)
# _streaming_program_name も同様
text_to_process = self._streaming_buffer + appended_text_segment
self._streaming_buffer = "" # 処理するので一旦クリア
#改行コードで終わっている。
end_is_new_line = appended_text_segment.endswith(('\n', '\r\n'))
lines = text_to_process.split('\n')
#print("self.blok_type",self.blok_type)
#print("DEBUG: appended_text_segment",end_is_new_line,appended_text_segment)
# print("DEBUG: _extract_and_append_incremental len lines" ,len(lines))
for i ,line in enumerate(lines):
line = line.rstrip()
if self.NOMAL_BLOCK == self.blok_type:
# nomal blockの時
# 正規表現でコードブロックを検索
match = re.search(r'^```(.*?)', line, re.DOTALL)
if match:
self.blok_type = self.PROGRAM_BLOCK
program_name = line[3:]
program = PaintTitelAndContents(program_name, "")
# program = program.get_main_box()
self._prepare_content_object(program)
self.append_contents_object(program, self.contents)
continue
else:
# purogram blockの時
match = re.search(r'```$', line, re.DOTALL) # 正規表現でコードブロックを検索
if match:
label = PaintLabelBox("")
self._prepare_content_object(label)
label.set_border(0)
self.append_contents_object(label, self.contents)
continue
#text_buf += line + "\n"
if None is self.contents or 0 == len(self.contents):
self.blok_type = self.NOMAL_BLOCK
self.contents=[]
# ここまでのテキストデータをラベルとして追加
label = PaintLabelBox("")
self._prepare_content_object(label)
label.set_border(0)
self.append_contents_object(label, self.contents)
# 初期化
# print("DEBUG; line ",line)
if i == len(lines) - 1:
# print("DEBUG: _extract_and_append_incremental line" ,line)
self.contents[-1].append_text(line)
pass
# if end_is_new_line:
# self.contents[-1].append_text("\n")
else:
# print("DEBUG: _extract_and_append_incremental i lne(lines)" ,i, len(lines))
self.contents[-1].append_text(line + "\n")
self._prepare_content_object(self.contents)
self.__fit_height()
def extract_code_block(self, text): # コードブロックの抽出
contents_buf = []
self.blok_type = self.NOMAL_BLOCK
self.contents_text = text # Store the original full text for copy-paste
lines = text.split("\n")
program_name = ""
text_buf = ""
for line in lines:
line = line.rstrip()
if self.NOMAL_BLOCK == self.blok_type:
# nomal blockの時
# 正規表現でコードブロックを検索
match = re.search(r'^```(.*?)', line, re.DOTALL)
if match:
self.blok_type = self.PROGRAM_BLOCK
program_name = line[3:]
# ここまでのテキストデータをラベルとして追加
text_buf = text_buf.rstrip()
label = PaintLabelBox(text_buf)
label.set_font_size(self.current_font_size) # Apply current node font size
label.set_border(0)
self.__set_label_width_and_position(label)
self.append_contents_object(label, contents_buf)
# 初期化
self.last_text = text_buf
text_buf = ""
continue
else:
# purogram blockの時
match = re.search(r'```$', line, re.DOTALL) # 正規表現でコードブロックを検索
if match:
self.blok_type = self.NOMAL_BLOCK
# ここまでのテキストデータをプログラムとして追加
text_buf = text_buf.rstrip()
program = PaintTitelAndContents(program_name, text_buf)
# program = program.get_main_box()
program.set_font_size_for_all_elements(self.current_font_size) # Apply current node font size
self.__set_titelandcontents_width_and_position(program)
self.append_contents_object(program, contents_buf)
# 初期化
program_name = ""
self.last_text = text_buf
text_buf = ""
continue
text_buf += line + "\n"
if self.PROGRAM_BLOCK == self.blok_type:
# プログラムブロックを終了する前に終了した
# program block
text_buf = text_buf.rstrip()
program = PaintTitelAndContents(program_name, text_buf)
program.set_font_size_for_all_elements(self.current_font_size) # Apply current node font size
self.__set_titelandcontents_width_and_position(program)
self.append_contents_object(program, contents_buf)
self.blok_type = self.NOMAL_BLOCK
else:
# ノーマルブロックの状態で終了した。
# nomal block
text_buf = text_buf.rstrip()
label = PaintLabelBox(text_buf)
label.set_font_size(self.current_font_size) # Apply current node font size
label.set_border(0)
self.__set_label_width_and_position(label)
self.append_contents_object(label, contents_buf)
self.contents = contents_buf
self.last_text = text_buf
self.__fit_height()
def __fit_height(self):
# original_main_height = self.main.height()
h = self.main.get_title_height() # タイトルの高さ
count = 0
for obj in self.contents:
count += 1
obj.set_position(obj.x(),
self.y() + h +
self.main.get_contents_border() * count)
h += obj.height() # 各コンテンツの高さを加算
#new_contents_height = h - self.main.get_title_height() + self.main.get_contents_border() * count * 2
self.set_contents_height(h-self.main.get_title_height() +
self.main.get_contents_border()*count*2)
#if 0 == new_contents_height:
#print(f"h:{h}, self.main.get_title_height() {self.main.get_title_height()}, self.main.get_contents_border() {self.main.get_contents_border()}, count:{count}")
# final_main_height = self.main.height()
# if final_main_height < original_main_height:
# if 100 < len(self.main.contents.get_text()) and final_main_height < 100:
#
# print(f"DEBUG WARNING: Main height DECREASED in {self.__class__.__name__}.{inspect.currentframe().f_code.co_name}")
# print(f" Target: self.main, Original: {original_main_height}, Final: {final_main_height}, Diff: {final_main_height - original_main_height}")
#
# print(" Call Stack:")
# for frame_info in inspect.stack():
# print(f" File: {frame_info.filename}, Line: {frame_info.lineno}, Function: {frame_info.function}")
# if frame_info.code_context:
# print(f" Code: {frame_info.code_context[0].strip()}")
def draw(self, obj):
self.main.draw(obj)
for pobj in self.contents:
pobj.draw(obj)
class PaintChatThread():
def __init__(self):
self.nodes = []
self.draw_area = QRect(0, 0, 100, 1000)
self.draw_start_index = 0
self.node_space = 10
def mouse_down(self, pos):
pos = QPoint(self.draw_area.x() + pos.x(),
self.draw_area.y() + pos.y())
for node in self.nodes:
node.mouse_down(pos)
def mouse_up(self, pos):
pos = QPoint(self.draw_area.x() + pos.x(),
self.draw_area.y() + pos.y())
for node in self.nodes:
node.mouse_up(pos)
def mouse_hover(self, pos):
pos = QPoint(self.draw_area.x() + pos.x(),
self.draw_area.y() + pos.y())
for node in self.nodes:
node.mouse_hover(pos)
def append(self, node):
self.nodes.append(node)
if 1 < len(self.nodes):
# self.nodes[-1].set_position(
# self.nodes[-2].x(),
# self.nodes[-2].y() + self.nodes[-2].height() + self.node_space * (len(self.nodes)-1))
self.nodes[-1].set_position(
self.nodes[-2].x(),
self.nodes[-2].y() + self.nodes[-2].height() + self.node_space)
def append_node(self, title, contents):
node = PaintChatNode(title, contents)
if 0 < len(self.nodes):
n_bottom = self.nodes[-1].y() + self.nodes[-1].height()
da_bottom = self.draw_area_y() + self.draw_area_height()
width = self.width()
node.set_width(width)
else:
width = self.width()
node.set_width(width)
self.append(node)
if 1 < len(self.nodes):
# print("n_bottom", n_bottom)
# print("da_bottom", self.draw_area_y() + self.draw_area_height())
if n_bottom == da_bottom:
self.set_draw_area_y(self.nodes[-1].y() + self.nodes[-1].height() - self.draw_area_height())
elif n_bottom <= da_bottom:
post_bottom = self.draw_area_y() + self.draw_area_height()
if da_bottom <= post_bottom:
self.set_draw_area_y(self.nodes[-1].y() + self.nodes[-1].height() - self.draw_area_height())
def append_text(self, text):
pre_draw_bottom = self.draw_area_y() + self.draw_area_height()
pre_contentts_height = self.height()
if 0 < len(self.nodes):
self.nodes[-1].append_text(text)
self.__set_draw_start_index()
if pre_draw_bottom == pre_contentts_height:
self.set_draw_area_y(self.height()-self.draw_area_height())
elif pre_contentts_height <= pre_draw_bottom:
post_bottom = self.draw_area_y() + self.draw_area_height()
if pre_draw_bottom <= post_bottom:
self.set_draw_area_y(self.height()-self.draw_area_height())
def set_last_node_text(self, text):
pre_draw_bottom = self.draw_area_y() + self.draw_area_height()
pre_contentts_height = self.height()
if 0 < len(self.nodes):
self.nodes[-1].set_contents(text)
self.__set_draw_start_index()
if pre_draw_bottom == pre_contentts_height:
self.set_draw_area_y(self.height()-self.draw_area_height())
def height(self):
if 0 < len(self.nodes):
return self.nodes[-1].y() + self.nodes[-1].height()
else:
return 0
def width(self):
if 0 < len(self.nodes):
return self.nodes[-1].width()
else:
return 0
def draw_area_height(self):
return self.draw_area.height()
def draw_area_y(self):
return self.draw_area.y()
def set_draw_area_y(self, y):
self.set_draw_area(self.draw_area.x(),
y,
self.draw_area.width(),
self.draw_area.height())
def set_draw_area(self, x, y, width, height):
pre_y = self.draw_area.y()
if y < 0:
y = 0
self.draw_area.setRect(x, y, width, height)
reverse = True
if pre_y < self.draw_area.y():
reverse = False
self.__set_draw_start_index(reverse)
def set_width(self, w):
y_deff_buf = -1
y_buf = -1
if 0 < len(self.nodes):
change_flag = True
for i in range(len(self.nodes)):
# 一番上の 描画 ノードとの関係を固定
if 0 == i:
y_deff_buf = self.draw_area.y() - self.nodes[i].y()
change_flag = True
self.nodes[i].set_width(w)
if 0 < i:
self.nodes[i].set_position(
self.nodes[i - 1].x(),
self.nodes[i - 1].y() + self.nodes[i-1].height() + self.node_space)
# 一番上の 描画 ノードとの関係を固定
if 0 <= self.draw_area.y() - self.nodes[i].y():
y_deff_buf = self.draw_area.y() - self.nodes[i].y()
change_flag = True
if change_flag:
y_buf = self.nodes[i].y()
change_flag = False
# 描画範囲を設定
if self.nodes[-1].y()+self.nodes[-1].height() - self.draw_area.height() < y_buf + y_deff_buf:
y_deff_buf = (self.nodes[-1].y()+self.nodes[-1].height() - self.draw_area.height())
y_buf = 0
if y_buf + y_deff_buf < 0:
y_buf = 0
y_deff_buf = 0
self.draw_area.setRect(
self.draw_area.x(),
y_buf + y_deff_buf,
w,
self.draw_area.height())
def move_vertical(self, y):
self.draw_area.setRect(
self.draw_area.x(),
self.draw_area.y() + y,
self.draw_area.width(),
self.draw_area.height())
self.__set_draw_start_index(y < 0)
def __set_draw_start_index(self, reverse=False):
"""
描画エリア内に表示されるべき最初のノードのインデックスを決定する役割を担っている
"""
# ノードリストが存在する場合のみ処理を行う
if 0 < len(self.nodes):
# 現在の描画開始インデックスがノードリストの範囲外であれば、最後のノードのインデックスに調整する
if len(self.nodes) <= self.draw_start_index:
self.draw_start_index = len(self.nodes) -1
# 逆方向(上方向へスクロールなど)の場合の処理
if reverse:
# 現在の描画開始インデックスから逆順にノードをチェック
for i in range(self.draw_start_index, -1, -1):
# ノードiの下端が描画エリアの上端よりも下にあるか(つまり、ノードiが描画エリア内またはエリアより下にあるか)
if self.draw_area.y() < self.nodes[i].y() +self.nodes[i].height():
# ノードiの上端が描画エリアの下端よりも上にあるか(つまり、ノードiが描画エリア内に部分的にでも表示されているか)
if self.nodes[i].y() < self.draw_area.y() + self.draw_area.height():
# 条件を満たせば、このノードを描画開始インデックスとする
self.draw_start_index = i
# ノードiの下端が描画エリアの上端よりも上にある場合(つまり、ノードiが完全に描画エリアより上にある場合)
# それより前のノードも描画エリア外なので、ループを抜ける
if self.nodes[i].y() + self.nodes[i].height() < self.draw_area.y():
break
# 正方向(下方向へスクロールなど)の場合の処理
else:
# 現在の描画開始インデックスから順方向にノードをチェック
for i in range(self.draw_start_index, len(self.nodes)):
# ノードiの下端が描画エリアの上端よりも下にあるか
if self.draw_area.y() < self.nodes[i].y() + self.nodes[i].height():
# ノードiの上端が描画エリアの下端よりも上にあるか
if self.nodes[i].y() < self.draw_area.y() + self.draw_area.height():
# 条件を満たせば、このノードを描画開始インデックスとし、処理を終了
self.draw_start_index = i
return
def draw(self, obj):
if 0 < len(self.nodes):
for i in range(self.draw_start_index, len(self.nodes)):
if self.draw_area.y() < self.nodes[i].y() + self.nodes[i].height():
if self.nodes[i].y() < self.draw_area.y() + self.draw_area.height():
# draw area内のノードだけ描画
pre_x = self.nodes[i].x()
pre_y = self.nodes[i].y()
self.nodes[i].set_position(self.nodes[i].x()-self.draw_area.x(),
self.nodes[i].y()-self.draw_area.y())
self.nodes[i].draw(obj)
self.nodes[i].set_position(pre_x,
pre_y)
if self.draw_area.y() + self.draw_area.height() < self.nodes[i].y():
return
def get_draw_bottom(self):
return self.draw_area.x() + self.draw_area.height()
class PaintArrowSlider():
NO_STATE = 0
CLICK_UP = 1
CLICK_DOWN = 2
CLICK_SLIDER = 3
CLICK_SLIDER_UP = 4
CLICK_SLIDER_DOWN = 5
def __init__(self):
self.up_button = PaintButton("▲")
self.down_button = PaintButton("▼")
self.slider = PaintButton(" ")
self.slider.set_hieght_fit(False)
self.state = self.NO_STATE
self.slider_min = 10
self.rect = QRect(0, 0, 10, 100)
self.border = 0
self.y_position_ratio = 1
self.slider_height_ratio = 1
self.set_up_down_button_size(30, 30)
self.slider.set_width(30)
self.set_slider_height(1)
self.set_slider_position(0)
self.pre_mouse_position = QPoint(0, 0)
self.slider.set_clicked_back_color(QColor(224,224,224))
def x(self):
return self.up_button.x()
def y(self):
return self.up_button.y()
def width(self):
return self.up_button.width()
def height(self):
return self.rect.height()
def set_up_down_button_size(self, width, height):
now_height = self.height()
self.rect = QRect(self.rect.x(),
self.rect.y(),
width,
self.rect.height())
self.up_button.set_size(width, height)
self.down_button.set_size(width, height)
if now_height < height * 3:
now_height = height*3
self.set_height(now_height)
def set_slider_position(self, pos_ratio):
if 1 < pos_ratio:
pos_ratio = 1
if pos_ratio < 0:
pos_ratio = 0
self.y_position_ratio = pos_ratio
self.slider.set_position(
self.slider.x(),
self.up_button.height() + (self.down_button.y() -
(self.up_button.y() +
self.up_button.height() +
self.slider.height()))
* self.y_position_ratio)
def set_position(self, x, y):
self.up_button.set_position(x, y)
# self.slider.set_position(x, y)
self.down_button.set_position(
x,
self.rect.height() - self.down_button.height())
self.slider.set_position(x, y)
self.set_slider_position(self.y_position_ratio)
self.rect = QRect(x, y, self.rect.width(), self.rect.height())
def mouse_down(self, pos):
pos = QPoint(pos.x(),pos.y())
if self.up_button.mouse_down(pos):
self.state = self.CLICK_UP
elif self.down_button.mouse_down(pos):
self.state = self.CLICK_DOWN
elif self.slider.mouse_down(pos):
self.state = self.CLICK_SLIDER
self.pre_mouse_position = pos
elif self.rect.contains(pos):
if pos.y() < self.slider.y():
self.state = self.CLICK_SLIDER_UP
elif self.slider.y() + self.slider.height() < pos.y():
self.state = self.CLICK_SLIDER_DOWN
return self.state
def mouse_up(self, pos):
self.state = self.NO_STATE
self.up_button.mouse_up(pos)
self.down_button.mouse_up(pos)
self.slider.mouse_up(pos)
def mouse_hover(self, pos):
self.up_button.mouse_hover(pos)
self.down_button.mouse_hover(pos)
self.slider.mouse_hover(pos)
def mouse_move(self, pos):
vm = QPoint(int(pos.x() - self.pre_mouse_position.x()),
int(pos.y() - self.pre_mouse_position.y()))
if self.state == self.CLICK_SLIDER:
if vm.y() < 0:
if self.up_button.y() + self.up_button.height() <\
self.slider.y() + vm.y():
self.slider.set_position(
self.slider.x(),
self.slider.y() + vm.y())
else:
self.slider.set_position(
self.slider.x(),
self.up_button.y() + self.up_button.height())
else:
if self.slider.y() + self.slider.height() + vm.y() <\
self.down_button.y():
self.slider.set_position(
self.slider.x(),
self.slider.y() + vm.y())
else:
self.slider.set_position(
self.slider.x(),
self.down_button.y()-self.slider.height())
self.pre_mouse_position = pos
buf = self.slider.y()-(self.up_button.y()+self.up_button.height() )
self.y_position_ratio = (
buf/
(self.down_button.y() -
(self.slider.height() +
self.up_button.y() +
self.up_button.height()))
)
return vm
def get_state(self):
return self.state
def set_width(self, width):
self.rect = QRect(self.rect.x(), self.rect.y(),
width, self.rect.height())
self.up_button.set_size(width, self.up_button.height())
self.down_button.set_size(width, self.down_button.height())
self.slider.set_size(width, self.slider.height())
def set_height(self, height):
self.rect = QRect(self.rect.x(), self.rect.y(),
self.rect.width(), height)
ratio = self.get_slider_ratio()
self.down_button.set_position(self.down_button.x(),
height-self.down_button.height())
slider_area_height = self.down_button.y() - (self.up_button.height() + self.up_button.y())
self.slider.set_size(self.slider.width(), int(slider_area_height*ratio))
self.set_slider_position(self.y_position_ratio)
def set_rect(self, x , y, width, height):
self.set_position(x, y)
self.set_width(width)
self.set_height(height)
def get_rect(self):
return self.rect
def get_slider_position_ratio(self):
return self.y_position_ratio
def get_slider_ratio(self):
s = self.up_button.y() + self.up_button.height() - self.down_button.y()
return s / self.slider.height()
def set_slider_height(self, ratio):
if 1 <= ratio:
ratio = 1
s = self.down_button.y() - (self.up_button.y() + self.up_button.height())
s = s * ratio
self.slider.set_size(self.slider.width(), int(s))
if int(s) < self.slider_min:
self.slider.set_size(self.slider.width(), self.slider_min)
self.slider_height_ratio = ratio
def __draw_back(self, obj):
# スライダー部分の背景
painter = QPainter(obj)
painter.setPen(QPen(QColor(128, 128, 128), self.border)) # 枠線
painter.setBrush(QBrush(QColor(128, 128, 128))) # 塗りつぶし
rect = QRect(
self.up_button.x() + 1,
self.up_button.y()+self.up_button.height(),
self.up_button.width() - 4,
self.down_button.y() - (self.up_button.y() + self.up_button.height()))
# ボタン類の描画
painter.drawRect(rect)
def draw(self, obj):
self.__draw_back(obj)
self.up_button.draw(obj)
self.down_button.draw(obj)
self.slider.draw(obj)
class ChatPaintWidget(QWidget):
def __init__(self):
self.is_drawing = False # draw処理中かどうかを示すフラグ
super().__init__()
self.setWindowTitle("Graphics Text with Size and Newline - Debug Version")
self.font = QFont("Arial", 50)
self.name = QLabel("")
self.name.setMaximumWidth(1500)
self.layout = QVBoxLayout(self)
# self.layout.setContentsMargins(0,0,0,0) # レイアウトのマージンを0に
# self.layout.setSpacing(0) # レイアウト内のスペーシングを0に
self.layout.addWidget(self.name)
self.setLayout(self.layout)
self.chat_thread = PaintChatThread()
self.slider = PaintArrowSlider()
self.update_font_size(20)
# self.chat_thread.append_node(f"ai", "作業指示をしてください。") # 初期メッセージは comparison_gui 側で制御
# テスト用
# for i in range(100):
# self.chat_thread.append_node(f"user1_{i}", "testデータです。aaa1aaaa2aaaaaa3aaaaa4aaa5aaaaaa6aaaaa7aaaa8aa")
# self.chat_thread.append_node(f"ai1_{i}", "わかりました。\n```python\nprint('test')\na=2\nb=4\n```\n 終わりです。sssssssssssssssssssssssssssssssssssssssssssssssssssssss")
# self.chat_thread.append_node(f"user2_{i}", "どうなっているの?\nok")
def paintEvent(self, event):
start_time = time.perf_counter()
if not self.is_drawing:
self.is_drawing = True
try:
self.chat_thread.draw(self)
self.slider.draw(self)
finally:
self.is_drawing = False
end_time = time.perf_counter()
processing_time = (end_time - start_time) * 1000 # ミリ秒に変換
# paintEventは非常に頻繁に呼ばれるため、閾値を設けてログ出力を制御するのも良いでしょう
# if processing_time > 10: # 例えば10ms以上かかった場合のみ出力
# print(f"DEBUG: ChatPaintWidget.paintEvent executed in {processing_time:.3f} ms")
def __page_up(self, deff):
self.chat_thread.move_vertical(-deff)
if self.chat_thread.draw_area_y() < 0:
self.chat_thread.set_draw_area_y(0)
self.slider.set_slider_position(self.chat_thread.draw_area_y()/(self.chat_thread.height() - self.chat_thread.draw_area_height()))
def __page_down(self, deff):
self.chat_thread.move_vertical(deff)
if self.chat_thread.height() < self.chat_thread.draw_area_y() + self.chat_thread.draw_area_height():
self.chat_thread.set_draw_area_y(
self.chat_thread.height() - self.chat_thread.draw_area_height())
self.slider.set_slider_position(self.chat_thread.draw_area_y()/(self.chat_thread.height() - self.chat_thread.draw_area_height()))
def mousePressEvent(self, event: QMouseEvent):
self.chat_thread.mouse_down(event.position())
res = self.slider.mouse_down(event.position())
dh = self.chat_thread.draw_area_height()
if res == self.slider.CLICK_UP:
self.__page_up(int(dh/10))
elif res == self.slider.CLICK_DOWN:
self.__page_down(int(dh/10))
elif res == self.slider.CLICK_SLIDER_UP:
self.__page_up(int(dh/10*9))
elif res == self.slider.CLICK_SLIDER_DOWN:
self.__page_down(int(dh/10*9))
self.chat_thread.height()
self.chat_thread.get_draw_bottom()
self.update()
def mouseReleaseEvent(self, event: QMouseEvent):
self.chat_thread.mouse_up(event.position())
self.slider.mouse_up(event.position())
self.update()
def mouseMoveEvent(self, event: QMouseEvent):
self.chat_thread.mouse_hover(event.position())
self.slider.mouse_hover(event.position())
self.slider.mouse_move(event.position())
res = self.slider.get_state()
if res == self.slider.CLICK_SLIDER:
pr = self.slider.get_slider_position_ratio()
self.chat_thread.set_draw_area_y(int((self.chat_thread.height() - self.chat_thread.draw_area_height())*pr))
self.update()
def keyPressEvent(self, event: QKeyEvent):
key = event.key()
dh = self.chat_thread.draw_area_height()
if key == Qt.Key_PageUp:
self.__page_up(int(dh/10*9))
elif key == Qt.Key_PageDown:
self.__page_down(int(dh/10*9))
elif key == Qt.Key_Up:
self.__page_up(int(dh/10))
# 上矢印キーが押されたときの処理
elif key == Qt.Key_Down:
self.__page_down(int(dh/10))
self.chat_thread.height()
self.chat_thread.get_draw_bottom()
self.update()
def wheelEvent(self, event: QWheelEvent):
# event.angleDelta().y() は、垂直方向の回転量を返します。
# 正の値は上方向への回転、負の値は下方向への回転を示します。
delta = event.angleDelta().y()
dh = self.chat_thread.draw_area_height()
# 回転量に応じて処理を行います。
if delta > 0:
# 上方向への回転時の処理
self.__page_up(int(dh/10))
else:
self.__page_down(int(dh/10))
# 下方向への回転時の処理
self.chat_thread.height()
self.chat_thread.get_draw_bottom()
self.update()
def update_font_size(self, font_size=None):
if font_size is None:
# Fallback to a default if no font_size is provided
font_size = 15 # Default font size
self.font.setPointSize(font_size) # For the widget itself, if needed
# Propagate to all nodes
if hasattr(self, 'chat_thread') and hasattr(self.chat_thread, 'nodes'):
for node in self.chat_thread.nodes:
if hasattr(node, 'set_font_size_for_all_elements'):
node.set_font_size_for_all_elements(font_size)
self.update()
def __update_slider(self):
self.slider.set_rect(self.width()-self.slider.width(), 0, self.slider.width(), self.height())
if (0 == self.chat_thread.height()):
self.slider.set_slider_height(0)
else:
self.slider.set_slider_height(self.chat_thread.draw_area_height()/self.chat_thread.height())
self.slider.get_slider_position_ratio()
# print(f"self.chat_thread.height() {self.chat_thread.height()}")
if (self.chat_thread.height() - self.chat_thread.draw_area_height()) <=0:
self.slider.set_slider_position(0)
else:
self.slider.set_slider_position(self.chat_thread.draw_area_y()/max(1,(self.chat_thread.height() - self.chat_thread.draw_area_height())))
self.slider.get_slider_ratio()
def resizeEvent(self, event):
da_y = self.chat_thread.draw_area_y()
if self.chat_thread.height() < self.height():
da_y = 0
else:
if self.chat_thread.height() <= da_y + self.height():
da_y = self.chat_thread.height() - self.height()
elif self.chat_thread.height() <= da_y + self.chat_thread.draw_area_height():
da_y = self.chat_thread.height() - self.height()
self.chat_thread.height()
self.chat_thread.set_draw_area(
-10, da_y, self.width()-self.slider.width() - 10, self.height())
if self.chat_thread.width() != self.width()-self.slider.width()-20:
self.chat_thread.set_width(self.width()-self.slider.width()-20)
self.__update_slider()
self.update()
def __resize(self):
self.resizeEvent(None) # QResizeEvent を渡す必要はない
def append_node(self, title, text):
self.chat_thread.append_node(title, text)
self.__resize()
def append_text(self, text):
# 最後のノードにテキストを追記
self.chat_thread.append_text(text)
if self.is_drawing:
return
self.__resize() # テキスト追加で高さが変わる可能性があるので再計算
def set_last_node_text(self, text):
# 最後のノードのテキストを置き換え
self.chat_thread.set_last_node_text(text)
while self.is_drawing:
time.sleep(0.1)
self.__resize() # テキスト置換で高さが変わる可能性があるので再計算
def new_streaming_node(self, title):
# ストリーミング表示用の新しい空ノードを追加
self.chat_thread.append_node(title, "") # 初期テキストは空
self.__resize()
class ChatWidget(QWidget):
update_signal = Signal(str, str) # title, text用
def __init__(self, agent):
super().__init__()
self.chat_thread = ChatPaintWidget()
# title
self.title = QLabel("PaintGUI\r\n")
self.title.setFixedHeight(30)
self.title.setFont(QFont("Arial", 30))
self.title.setAlignment( Qt.AlignCenter)
self.sep1 = QLabel("-------------------------------------------------------------------------------------------------")
self.sep1.setFixedHeight(20)
self.sep1.setAlignment(Qt.AlignCenter)
font = QFont("Arial", 20)
font.setBold(True) # 太字に設定
self.sep1.setFont(font)
self.sep2 = QLabel("-------------------------------------------------------------------------------------------------")
self.sep2.setFixedHeight(20)
self.sep2.setAlignment(Qt.AlignCenter)
self.sep2.setFont(font)
# input 入力欄
self.input_edit = QTextEdit()
self.input_edit.setMaximumHeight(100)
self.send_button = QPushButton("Send")
self.send_button.clicked.connect(self.send_message)
# Layout for input area
input_layout = QHBoxLayout()
input_layout.addWidget(self.input_edit)
input_layout.addWidget(self.send_button)
# Main layout
main_layout = QVBoxLayout(self)
main_layout.addWidget(self.title)
main_layout.addWidget(self.sep1)
main_layout.addWidget(self.chat_thread)
main_layout.addWidget(self.sep2)
main_layout.addLayout(input_layout)
self.update_signal.connect(self._append_node_handler)
self.agent = agent
self.thread = None
def send_message(self):
message = self.input_edit.toPlainText()
if message:
self.send_button.setEnabled(False) # ボタンを無効化
self.input_edit.setReadOnly(True) # 入力欄を読み取り専用に
#############################################################
self.chat_thread.append_node("user", message)
self.input_edit.clear()
# ワーカースレッドを作成して処理を移す
self.thread = QThread()
self.worker = AppendNodeWorker(self.agent, message)
self.worker.moveToThread(self.thread)
# スレッドが開始したらワーカーのrunを実行
self.thread.started.connect(self.worker.run)
# 処理が終わったら結果を受け取る
self.worker.finished.connect(self.on_response_received)
# スレッド終了時にスレッドを削除
self.worker.finished.connect(self.thread.quit)
self.worker.finished.connect(self.worker.deleteLater)
self.thread.finished.connect(self.thread.deleteLater)
# スレッド開始
self.thread.start()
def on_response_received(self, response):
# レスポンスを受け取って表示
# AIAgent.get_respons の中で表示は更新されているはずなので、
# ここではUIの制御(ボタンの再有効化など)を行います。
# response引数はAppendNodeWorker.finishedから渡されますが、
# 現在の設計では表示には使用しません。
self.send_button.setEnabled(True) # ボタンを再度有効化
self.input_edit.setReadOnly(False) # 入力欄を編集可能に戻す
self.input_edit.setFocus() # 入力欄にフォーカスを戻す (任意)
def append_node(self, title, text):
#self.chat_thread.append_node(title, text)
self.update_signal.emit(title, text)
# QCoreApplication.postEvent(self, AppendNodeEvent(title, text))
def _append_node_handler(self, title, text):
# 実際の更新処理
self.chat_thread.append_node(title, text)
self.update() # QWidgetのupdateメソッド
QApplication.processEvents() # イベント即時処理
def update(self):
super().update()
QApplication.processEvents() # イベントを即座に処理
def append_text(self, text):
# self.chat_thread.append_text(text)
# self.update()
QCoreApplication.postEvent(self, AppendTextEvent(text))
def set_last_node_text(self, text):
# self.chat_thread.set_last_node_text(text)
QCoreApplication.postEvent(self, SetLastNodeText(text))
self.update()
def new_streaming_node(self, title):
# ChatPaintWidgetの新しいメソッドを呼び出す
# これはUIスレッドから直接呼び出されることを想定
self.chat_thread.new_streaming_node(title)
def set_agent(self, agent):
self.agent = agent
# self.update()
def customEvent(self, event):
if event.type() == AppendNodeEvent.EventType:
self.chat_thread.append_node(event.name, event.text)
self.update()
elif event.type() == AppendTextEvent.EventType:
self.chat_thread.append_text(event.text)
self.update()
elif event.type() == SetLastNodeText.EventType:
self.chat_thread.set_last_node_text(event.text)
self.update()
class AppendNodeWorker(QObject):
finished = Signal(str) # 処理が終わったときに結果を送信するシグナル
def __init__(self, agent, message):
super().__init__()
self.agent = agent
self.message = message
def run(self):
# 重い処理をここで行う
response = self.agent.get_respons(self.message)
self.finished.emit(response) # 結果をシグナルで送信
class AppendNodeEvent(QEvent):
# EventType = QEvent.registerEventType()
EventType = QEvent.Type(QEvent.registerEventType()) # Use QEvent.Type()
def __init__(self, name, text):
super().__init__(AppendNodeEvent.EventType)
self.name = name
self.text = text
class AppendTextEvent(QEvent):
# EventType = QEvent.registerEventType()
EventType = QEvent.Type(QEvent.registerEventType()) # Use QEvent.Type()
def __init__(self, text):
super().__init__(AppendTextEvent.EventType)
self.text = text
class SetLastNodeText(QEvent):
# EventType = QEvent.registerEventType()
EventType = QEvent.Type(QEvent.registerEventType()) # Use QEvent.Type()
def __init__(self, text):
super().__init__(SetLastNodeText.EventType)
self.text = text
class MyStreamlitCallbackHandler(BaseCallbackHandler):
def __init__(self, container):
self.container = container
self.token_buffer = ""
self.last_update_time = time.time()
self.update_interval = 0.1 # 更新間隔を秒単位で指定 (例: 0.1秒)
#self._is_cancel = False
def on_llm_new_token(self, token: str, **kwargs: Any) -> None:
#print("MyStreamlitCallbackHandler self._is_cancel",self._is_cancel)
InterruptedException.append_response(token)
if InterruptedException.is_cancelled():
InterruptedException.set_cancel(True)
raise BaseException("test exception")
raise InterruptedException("LLM stream cancelled by worker flag.")
raise "is canceld "
#print("token",token)
# self.token_buffer.append(token)
self.token_buffer += token
current_time = time.time()
# #print("DEBUG on_llm_new_token",token)
if current_time - self.last_update_time >= self.update_interval:
if self.token_buffer:
self.container.append_text(self.token_buffer)
self.token_buffer = ""
self.last_update_time = current_time
def on_llm_end(self, response: Any, **kwargs: Any) -> None:
"""LLMのストリーミングが終了したときに呼び出される"""
if self.token_buffer: # バッファに残りがあれば全て送信
self.container.append_text(self.token_buffer)
self.token_buffer = ""
# @classmethod
# def set_cancel(cls, cancel_flag):
# cls._is_cancel = cancel_flag
# print("MyStreamlitCallbackHandler self.set_cancel",cls._is_cancel)
# 必要に応じて他のコールバックメソッド (on_chain_endなど) にも同様のフラッシュ処理を追加
# def on_chain_end(self, outputs: Dict[str, Any], **kwargs: Any) -> None:
# self.flush_token_buffer()
_g_app_instance = None
_g_my_widget_instance = None
def _get_app_instance():
global _g_app_instance
# QApplication.instance() を使って既存のインスタンスを取得、なければ作成
_g_app_instance = QApplication.instance()
if _g_app_instance is None:
_g_app_instance = QApplication(sys.argv)
return _g_app_instance
def _get_my_widget_instance(agent=None):
global _g_my_widget_instance
if _g_my_widget_instance is None:
_g_my_widget_instance = ChatWidget(agent)
# ChatWidget のデフォルト設定 (必要に応じて)
_g_my_widget_instance.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding)
_g_my_widget_instance.setMinimumHeight(400)
# _g_my_widget_instance.setFixedWidth(1000) # 幅は親ウィジェットに追従させる方が良い場合もある
elif agent is not None:
_g_my_widget_instance.set_agent(agent)
return _g_my_widget_instance
def excute(agent):
app = _get_app_instance() # QApplicationインスタンスを取得または作成
my_widget = _get_my_widget_instance(agent)
# ChatWidget を表示するための親ウィジェットとレイアウト (オプション)
# main_widget_for_excute = QWidget()
# main_layout_for_excute = QVBoxLayout(main_widget_for_excute)
# main_layout_for_excute.addWidget(my_widget)
# main_widget_for_excute.show()
# もしChatWidgetが直接トップレベルウィンドウとして機能するなら上記は不要
my_widget.show()
# ComparisonGui.py がメインのイベントループを持つため、ここでは app.exec() を呼び出さない
# paintgui.py が単体で実行される場合は、if __name__ == '__main__': の中で app.exec() を呼び出す
def set_agent(agent):
my_widget = _get_my_widget_instance(agent)
# my_widget.set_agent(agent) # _get_my_widget_instance 内で処理される
def update():
my_widget = _get_my_widget_instance()
my_widget.update()
def append_node(name,text):
my_widget = _get_my_widget_instance()
my_widget.append_node(name, text)
def set_last_node_text(text):
my_widget = _get_my_widget_instance()
my_widget.set_last_node_text(text)
def new_streaming_node(title):
my_widget = _get_my_widget_instance()
if hasattr(my_widget, 'new_streaming_node'):
my_widget.new_streaming_node(title)
def get_stc_handler():
my_widget = _get_my_widget_instance()
return MyStreamlitCallbackHandler(my_widget)
def get_mywidget():
return _get_my_widget_instance()
if __name__ == '__main__':
app = _get_app_instance() # QApplicationインスタンスを取得または作成
# ChatWidget を含むメインウィンドウのセットアップ
main_widget = QWidget()
main_layout = QVBoxLayout(main_widget)
# from Agents.AIAgent import AIAgent # テスト用にインポート
# test_agent = AIAgent("DummyAgentForPaintGUI", "System prompt for dummy", [])
# my_widget = _get_my_widget_instance(test_agent)
my_widget = _get_my_widget_instance(None) # エージェントなしで初期化
main_layout.addWidget(my_widget)
# main_layout.addStretch(0) # 必要に応じて
main_widget.setWindowTitle("PaintGUI Standalone")
main_widget.setGeometry(100, 100, 1000, 700) # 適当なサイズ
main_widget.show()
sys.exit(app.exec())
AIによる説明
PaintGUI ライブラリ リファレンスマニュアル
このドキュメントは、Pythonを用いたGUIライブラリ PaintGUI の使用方法を説明します。PySide6 をベースに、チャット風のインターフェースでテキストとコードブロックを表示する機能を提供します。
1. モジュール構成
PaintGUI は以下のクラスから構成されます。
PaintLabelBox: テキストを表示するラベルボックス。テキストの自動改行、フォントサイズ変更、位置調整、枠線設定などをサポートします。PaintButton: ボタンとして機能するラベルボックス。マウスイベントに応じた状態変化(通常、ホバー、クリック)と、背景色の変更をサポートします。PaintChatTitleBox: タイトルとコピーボタンを組み合わせたコンポーネント。タイトルテキストの設定、フォントサイズ変更、サイズ調整、位置調整をサポートします。コピーボタンを押すと、タイトルテキストがクリップボードにコピーされます。PaintTitelAndContents: タイトル(PaintChatTitleBox)とコンテンツ(PaintLabelBox)を組み合わせたコンポーネント。コンテンツの高さ自動調整、テキストの追加、フォントサイズ変更、幅と位置の調整をサポートします。PaintChatNode: チャットメッセージノードを表すコンポーネント。タイトル、コンテンツ、コードブロックを管理します。コードブロックは““`で囲まれたテキストとして認識されます。ストリーミング表示にも対応しています。PaintChatThread: チャットメッセージノードを管理するコンテナ。ノードの追加、描画領域の管理、スクロール機能を提供します。PaintArrowSlider: スクロールバーとして機能するコンポーネント。上下ボタンとスライダーで描画領域を制御します。ChatPaintWidget:PaintChatThreadとPaintArrowSliderを統合したウィジェット。マウスイベント、キーボードイベント、ホイールイベントに対応し、描画領域の更新を行います。ChatWidget:ChatPaintWidgetを含むメインウィジェット。メッセージ入力欄と送信ボタンを提供し、AIAgent(別途定義が必要) と連携してチャット機能を実現します。AppendNodeWorker:AIAgentとの通信をバックグラウンドで行うワーカースレッド。AppendNodeEvent,AppendTextEvent,SetLastNodeText:ChatWidgetでカスタムイベント処理を行うためのイベントクラス。MyStreamlitCallbackHandler: Langchain のコールバックハンドラ。LLMからのストリーミング応答を処理し、ChatPaintWidgetにテキストを追加します。
2. 主要クラスの詳細
2.1 PaintLabelBox
__init__(self, text=""): コンストラクタ。初期テキストを設定します。set_font_size(self, size): フォントサイズを設定します。set_size(self, width, hight): 幅と高さを設定します。set_width(self, w): 幅を設定します。set_position(self, x, y): 位置を設定します。move(self, x, y): 指定した分だけ移動します。set_text(self, text): テキストを設定します。append_text(self, text): テキストを追加します。set_span(self, span): 改行幅を設定します。set_border(self, bsize): 枠線の太さを設定します。draw(self, obj): 指定されたペイントデバイス(obj)に描画します。
2.2 PaintButton
PaintLabelBox を継承したボタンクラス。
__init__(self, text=""): コンストラクタ。check_point_in(self, pos): 指定された座標がボタン内にあるか判定します。mouse_down(self, fpos): マウスボタン押下イベントを処理します。mouse_up(self, fpos): マウスボタン解放イベントを処理します。mouse_hover(self, fpos): マウスホバーイベントを処理します。set_back_color(self, color): 通常時の背景色を設定します。set_clicked_back_color(self, color): クリック時の背景色を設定します。set_hover_back_color(self, color): ホバー時の背景色を設定します。draw(self, obj): 指定されたペイントデバイス(obj)に描画します。
2.3 PaintChatNode
チャットメッセージノードを表すクラス。
__init__(self, title=" ", contents=" "): コンストラクタ。タイトルとコンテンツを設定します。set_font_size_for_all_elements(self, new_size): 全ての要素のフォントサイズを変更します。mouse_down(self, pos): マウスボタン押下イベントを処理します。mouse_up(self, pos): マウスボタン解放イベントを処理します。mouse_hover(self, pos): マウスホバーイベントを処理します。set_title(self, text): タイトルを設定します。set_contents(self, text): コンテンツを設定します。append_text(self, text_segment): コンテンツにテキストを追加します。(ストリーミング対応)extract_code_block(self, text): テキストからコードブロックを抽出し、PaintTitelAndContentsオブジェクトとしてコンテンツに追加します。draw(self, obj): 指定されたペイントデバイス(obj)に描画します。
2.4 ChatPaintWidget
PaintChatThread と PaintArrowSlider を統合したウィジェット。
__init__(self): コンストラクタ。paintEvent(self, event): ペイントイベントを処理します。mousePressEvent(self, event): マウスボタン押下イベントを処理します。mouseReleaseEvent(self, event): マウスボタン解放イベントを処理します。mouseMoveEvent(self, event): マウス移動イベントを処理します。keyPressEvent(self, event): キーボードイベントを処理します。wheelEvent(self, event): ホイールイベントを処理します。update_font_size(self, font_size=None): フォントサイズを変更します。append_node(self, title, text): 新しいノードを追加します。append_text(self, text): 最後のノードにテキストを追加します。set_last_node_text(self, text): 最後のノードのテキストを置き換えます。new_streaming_node(self, title): ストリーミング表示用の新しいノードを追加します。resizeEvent(self, event): サイズ変更イベントを処理します。
2.5 ChatWidget
ChatPaintWidget を含むメインウィジェット。
__init__(self, agent): コンストラクタ。AIAgentインスタンスを受け取ります。send_message(self): メッセージ送信ボタンが押されたときに呼び出されます。append_node(self, title, text): 新しいノードを追加します。(シグナル経由)append_text(self, text): 最後のノードにテキストを追加します。(カスタムイベント経由)set_last_node_text(self, text): 最後のノードのテキストを置き換えます。(カスタムイベント経由)new_streaming_node(self, title): ストリーミング表示用の新しいノードを追加します。customEvent(self, event): カスタムイベントを処理します。
2.6 MyStreamlitCallbackHandler
Langchain のコールバックハンドラ。
__init__(self, container): コンストラクタ。ChatPaintWidgetインスタンスを受け取ります。on_llm_new_token(self, token, **kwargs): LLMから新しいトークンを受け取ったときに呼び出されます。on_llm_end(self, response, **kwargs): LLMからのストリーミングが終了したときに呼び出されます。
3. 関数
excute(agent): GUI を実行します。AIAgentインスタンスを受け取ります。set_agent(agent):AIAgentを設定します。update(): GUI を更新します。append_node(name, text): 新しいノードを追加します。set_last_node_text(text): 最後のノードのテキストを更新します。new_streaming_node(title): ストリーミング用の新しいノードを作成します。get_stc_handler():MyStreamlitCallbackHandlerのインスタンスを取得します。get_mywidget():ChatWidgetのインスタンスを取得します。
4. 使用例
# from Agents.AIAgent import AIAgent # 適切なエージェントクラスをインポート
# agent = AIAgent(...) # エージェントインスタンスを作成
import paintgui
# paintgui.excute(agent) # エージェントを指定して実行
# または、エージェントなしで実行し、後で設定する
paintgui.excute(None)
# ... 後でエージェントを設定 ...
# paintgui.set_agent(agent)
# テキストを追加
paintgui.append_node("user", "Hello, world!")
paintgui.append_node("ai", "Hello to you too!")
# ストリーミング表示を開始
paintgui.new_streaming_node("ai_streaming")
# ... ストリーミングテキストを少しずつ追加 ...
paintgui.get_mywidget().append_text("This is a streaming ")
paintgui.get_mywidget().append_text("response.")
# 最後のノードのテキストを更新
paintgui.set_last_node_text("Updated text.")
# GUIの更新
paintgui.update()
5. 注意点
- このライブラリは
PySide6に依存します。インストールが必要です。 - ストリーミング表示は、
MyStreamlitCallbackHandlerとAIAgentのget_responsメソッドの連携によって実現されます。get_responsメソッドは、トークン単位で応答を返す必要があります。 - エラー処理は簡略化されています。本番環境ではより詳細なエラー処理を実装する必要があります。
- 高さ調整に関する警告メッセージは、デバッグ用です。本番環境ではコメントアウトすることを推奨します。
このマニュアルが、PaintGUI ライブラリを使用する際の助けになれば幸いです。 より詳細な情報や具体的な使用方法については、ソースコードを参照してください。
