Compare commits

..

24 Commits

Author SHA1 Message Date
pys306
365f4d31d2 feat(frontend): translate UI strings to Korean 2026-04-28 00:02:25 +09:00
pys306
475c203ea1 feat: Add empty response counter logic to ask user for confirmation after 5 retries 2026-04-27 23:37:46 +09:00
pys306
50a7544a55 Update server address to 0.0.0.0 2026-04-27 22:31:07 +09:00
pys306
bf8914856e Update Streamlit address to 192.168.50.101 for external access 2026-04-26 02:29:42 +09:00
Liang Jiaqing
cd0ce4d9b0 fix: summary提取与fold_turns防误切,强化thinking模型summary输出 2026-04-26 00:01:42 +08:00
Liang Jiaqing
69f66997fd fix: fallback to getpass.getuser() when os.getlogin() raises OSError 2026-04-25 22:55:17 +08:00
LJQ
567c7f582b Merge pull request #140 from huangrichao2020/feat/macos-desktop-app
feat: add macOS Desktop App installation script
2026-04-25 22:17:44 +08:00
LJQ
adf393c0a5 Merge pull request #173 from Stark-X/fix/linux-desktop-pet-pyside6
fix: add Linux desktop pet implementation
2026-04-25 22:09:35 +08:00
LJQ
12f23138e0 Merge pull request #164 from kukudelaomao/fix/wecomapp-event-handlers
fix(wecomapp): 修复WebSocket连接和事件处理器问题
2026-04-25 22:04:31 +08:00
LJQ
c19ac2dae6 Merge pull request #160 from YooooEX/telegram-polish
fix(tgapp): polish Telegram MarkdownV2 rendering and startup helpers
2026-04-25 21:59:49 +08:00
LJQ
5805566d88 Fix Telegram startup without a configured proxy
Fix Telegram startup without a configured proxy
2026-04-25 21:51:54 +08:00
LJQ
da91a0643f fix(sandbox): patch Popen + block startfile to prevent window leaks
fix(sandbox): patch Popen + block startfile to prevent window leaks
2026-04-25 21:49:15 +08:00
Liang Jiaqing
5b0d96b1b5 tg: support document upload, disable draft streaming; wx: streaming flush during task; llmcore: safeprint, max_tokens fixes 2026-04-25 21:33:38 +08:00
Jiaqing Liang
10776fed51 Remove responses max output token limit 2026-04-25 14:03:21 +08:00
Auston Li
bd2dd42ba4 Fix Telegram startup when proxy is not configured
Only use a proxy for tgapp when mykeys['proxy'] is explicitly configured, avoiding the dead default local proxy and restoring Telegram polling startup.

Closes #175
2026-04-25 00:54:42 -04:00
Stark-X
0f77470fe0 Merge branch 'lsdefine:main' into fix/linux-desktop-pet-pyside6 2026-04-25 11:20:11 +08:00
MuziIsabel
3b93c6137d fix(sandbox): patch Popen + block startfile to prevent window leaks
subprocess.run was already patched with CREATE_NO_WINDOW, but Popen and
os.startfile were unprotected. Agent code could open visible GUI windows
via subprocess.Popen(['notepad.exe']) or os.startfile().
2026-04-25 10:59:15 +08:00
Stark-X
09cf6306af fix: add Linux desktop pet implementation 2026-04-25 10:58:38 +08:00
郭春飞
936501069c fix(wecomapp): 修复WebSocket连接和事件处理器问题
修复企业微信机器人无法正常连接的问题:

1. 修复WSClient初始化参数传递错误
   - 原代码错误地将参数传递给WSClient构造函数
   - 修正为正确传递host, port, path等参数

2. 修复连接方法调用错误
   - 将connect_async()改为connect()方法
   - AiBotSDK的WSClient使用同步connect方法

3. 修复事件处理器签名不匹配
   - on_connected/on_authenticated: 移除frame参数(这些处理器不需要参数)
   - on_disconnected: 添加reason参数
   - on_error: 添加error参数

修复后验证:
- WebSocket连接成功建立
- 认证过程正常完成
- 心跳机制正常工作
- 日志无错误信息

此修复解决了企业微信机器人启动后无法连接服务器的问题。
2026-04-25 01:18:28 +08:00
YooooEX
8e159a9773 feat(tgapp): add support for __ bold and ~~ strikethrough styles 2026-04-24 20:11:40 +08:00
YooooEX
0c5d3c8635 fix(tgapp): align startup with common helpers and render file marker basenames 2026-04-24 20:11:40 +08:00
Huang richao
446d7bf549 Merge remote-tracking branch 'origin/main' into feat/macos-desktop-app 2026-04-24 17:53:18 +08:00
Huang richao
b34965b936 fix: narrow macOS app installer per review 2026-04-24 17:52:47 +08:00
Huang richao
7255515607 feat: add macOS Desktop App installation script
- Add scripts/install-macos-app.sh for one-click macOS app installation
- Uses bundled assets/images/logo.jpg as app icon
- AppleScript prompts for project folder on first run
- Updates README with macOS Desktop App installation option
- Supports both interactive and --auto non-interactive modes

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-04-23 23:19:24 +08:00
13 changed files with 751 additions and 261 deletions

View File

@@ -19,4 +19,12 @@ def _run(*a, **k):
if r.stderr is not None: r.stderr = _d(r.stderr)
return r
subprocess.run = _run
_Pi = subprocess.Popen.__init__
def _pinit(self, *a, **k):
if os.name == 'nt': k['creationflags'] = (k.get('creationflags') or 0) | 0x08000000
_Pi(self, *a, **k)
subprocess.Popen.__init__ = _pinit
if hasattr(os, 'startfile'):
def _nosf(*a, **k): raise RuntimeError("startfile disabled in sandbox")
os.startfile = _nosf
sys.excepthook = lambda t, v, tb: (sys.__excepthook__(t, v, tb), print(f"\n[Agent Hint]: NO GUESSING! You MUST probe first. If missing common package, pip.")) if issubclass(t, (ImportError, AttributeError)) else sys.__excepthook__(t, v, tb)

179
assets/install-macos-app.sh Executable file
View File

@@ -0,0 +1,179 @@
#!/bin/bash
# GenericAgent macOS Desktop App Installation Script
#
# Usage:
# bash assets/install-macos-app.sh [--auto]
#
# This installer creates a small .app bundle that opens Terminal and runs
# `python3 launch.pyw` from the current GenericAgent checkout.
if [ -z "${BASH_VERSION}" ]; then
if command -v bash >/dev/null 2>&1; then
exec bash -- "${0}" "$@"
else
echo "Error: This script requires bash."
exit 1
fi
fi
set -euo pipefail
RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; BLUE='\033[0;34m'; CYAN='\033[0;36m'; NC='\033[0m'
log_info() { echo -e "${BLUE} $1${NC}"; }
log_success() { echo -e "${GREEN}$1${NC}"; }
log_warning() { echo -e "${YELLOW}⚠️ $1${NC}"; }
log_error() { echo -e "${RED}$1${NC}"; }
AUTO_MODE=false
for arg in "$@"; do
case "$arg" in
--auto) AUTO_MODE=true ;;
esac
done
APP_NAME="GenericAgent"
PRIMARY_INSTALL_DIR="/Applications"
FALLBACK_INSTALL_DIR="${HOME}/Applications"
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PROJECT_ROOT="$(cd "${SCRIPT_DIR}/.." && pwd)"
ICON_PATH="${PROJECT_ROOT}/assets/images/logo.jpg"
LAUNCH_SCRIPT="${PROJECT_ROOT}/launch.pyw"
echo -e "${CYAN}"
echo "╔═══════════════════════════════════════════════════════════╗"
echo "║ GenericAgent — macOS Desktop App Installer ║"
echo "╚═══════════════════════════════════════════════════════════╝"
echo -e "${NC}"
if [[ "$(uname)" != "Darwin" ]]; then
log_error "This script only supports macOS."
exit 1
fi
if ! command -v python3 >/dev/null 2>&1; then
log_error "python3 is not installed."
exit 1
fi
if [ ! -f "${LAUNCH_SCRIPT}" ]; then
log_error "launch.pyw not found at ${LAUNCH_SCRIPT}"
exit 1
fi
project_path_for_applescript="${PROJECT_ROOT}/"
project_path_for_applescript="${project_path_for_applescript//\\/\\\\}"
project_path_for_applescript="${project_path_for_applescript//\"/\\\"}"
detect_existing_app() {
if [ -d "${PRIMARY_INSTALL_DIR}/${APP_NAME}.app" ]; then
echo "${PRIMARY_INSTALL_DIR}/${APP_NAME}.app"
return
fi
if [ -d "${FALLBACK_INSTALL_DIR}/${APP_NAME}.app" ]; then
echo "${FALLBACK_INSTALL_DIR}/${APP_NAME}.app"
return
fi
}
existing_app_path="$(detect_existing_app || true)"
if [ -n "${existing_app_path}" ]; then
log_warning "${APP_NAME}.app already exists at ${existing_app_path}"
fi
if [ "${AUTO_MODE}" = false ]; then
echo ""
echo "This will install a desktop app that launches GenericAgent"
echo "from Spotlight, Launchpad, or the Applications folder."
echo ""
if [ -n "${existing_app_path}" ]; then
read -p "Reinstall ${APP_NAME}.app? (y/N) " -n 1 -r
else
read -p "Continue? (Y/n) " -n 1 -r
fi
echo
if [ -n "${existing_app_path}" ]; then
[[ ! ${REPLY:-} =~ ^[Yy]$ ]] && { echo "Aborted."; exit 0; }
else
[[ ${REPLY:-} =~ ^[Nn]$ ]] && { echo "Aborted."; exit 0; }
fi
fi
TMP_DIR="$(mktemp -d)"
trap 'rm -rf "${TMP_DIR}"' EXIT
log_info "Building ${APP_NAME}.app..."
cat > "${TMP_DIR}/${APP_NAME}.applescript" <<APPLESCRIPT
on run
set projectPathStr to "${project_path_for_applescript}"
tell application "Terminal"
activate
do script "cd " & quoted form of projectPathStr & " && python3 launch.pyw"
end tell
end run
APPLESCRIPT
osacompile -o "${TMP_DIR}/${APP_NAME}.app" "${TMP_DIR}/${APP_NAME}.applescript"
log_info "Applying GenericAgent icon..."
if [ -f "${ICON_PATH}" ]; then
ICONSET_DIR="${TMP_DIR}/ga-icon.iconset"
mkdir -p "${ICONSET_DIR}"
sips -z 16 16 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_16x16.png" >/dev/null 2>&1
sips -z 32 32 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_16x16@2x.png" >/dev/null 2>&1
sips -z 32 32 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_32x32.png" >/dev/null 2>&1
sips -z 64 64 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_32x32@2x.png" >/dev/null 2>&1
sips -z 128 128 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_128x128.png" >/dev/null 2>&1
sips -z 256 256 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_128x128@2x.png" >/dev/null 2>&1
sips -z 256 256 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_256x256.png" >/dev/null 2>&1
sips -z 512 512 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_256x256@2x.png" >/dev/null 2>&1
sips -z 512 512 "${ICON_PATH}" --out "${ICONSET_DIR}/icon_512x512.png" >/dev/null 2>&1
cp "${ICON_PATH}" "${ICONSET_DIR}/icon_512x512@2x.png"
iconutil -c icns "${ICONSET_DIR}" -o "${TMP_DIR}/ga-icon.icns"
cp "${TMP_DIR}/ga-icon.icns" "${TMP_DIR}/${APP_NAME}.app/Contents/Resources/applet.icns"
log_success "Icon applied from assets/images/logo.jpg"
else
log_warning "Logo not found at ${ICON_PATH}, using default icon."
fi
install_bundle() {
local install_dir="$1"
local destination="${install_dir}/${APP_NAME}.app"
mkdir -p "${install_dir}"
rm -rf "${destination}"
cp -R "${TMP_DIR}/${APP_NAME}.app" "${destination}"
}
install_path=""
if install_bundle "${PRIMARY_INSTALL_DIR}" 2>/dev/null; then
install_path="${PRIMARY_INSTALL_DIR}/${APP_NAME}.app"
else
log_warning "Could not write to ${PRIMARY_INSTALL_DIR}; falling back to ${FALLBACK_INSTALL_DIR}"
install_bundle "${FALLBACK_INSTALL_DIR}"
install_path="${FALLBACK_INSTALL_DIR}/${APP_NAME}.app"
fi
log_success "Installed to: ${install_path}"
echo ""
echo -e "${CYAN}╔═══════════════════════════════════════════════════════════╗${NC}"
echo -e "${CYAN}${NC}${APP_NAME} Desktop App installed successfully! ${CYAN}${NC}"
echo -e "${CYAN}╚═══════════════════════════════════════════════════════════╝${NC}"
echo ""
echo -e "${BLUE}Launch methods:${NC}"
echo " • Spotlight: Cmd + Space → type '${APP_NAME}' → Enter"
echo " • Launchpad: Find the '${APP_NAME}' icon"
echo " • Finder: Open ${install_path}"
echo ""
echo -e "${BLUE}Runtime behavior:${NC}"
echo " The app uses the current checkout path embedded at install time:"
echo " ${PROJECT_ROOT}"
echo " If you move the repo later, re-run this installer."
echo ""
echo -e "${BLUE}Uninstall:${NC}"
echo " rm -rf '${install_path}'"
echo ""

View File

@@ -1,6 +1,6 @@
# Role: 物理级全能执行者
你拥有文件读写、脚本执行、用户浏览器JS注入、系统级干预的物理操作权限。禁止推诿"无法操作"——不空想,用工具探测。
## 行动原则
调用工具前在<thinking>内推演:当前阶段、上步结果是否符合预期、下步策略;<summary>输出极简总结。
调用工具前推演:当前阶段、上步结果是否符合预期、下步策略;回复文本中用<summary>输出极简总结。
- 探测优先:失败时先充分获取信息(日志/状态/上下文),关键信息存入工作记忆,再决定重试或换方案。不可逆操作先询问用户。
- 失败升级1次→读错误理解原因2次→探测环境状态3次→深度分析后换方案或问用户。禁止无新信息的重复操作。

View File

@@ -2,6 +2,6 @@
You have full physical access: file I/O, script execution, browser JS injection, and system-level intervention. Never deflect with "can't do it" — don't speculate, use tools to probe.
Summarize and reply in user's language or follow user's prompt.
## Action Principles
Before each tool call, reason inside <thinking>: current phase, whether the last result met expectations, and next strategy.
Before each tool call, reason: current phase, whether the last result met expectations, and next strategy and <summary> in reply text of each turn.
- Probe first: on failure, gather sufficient info (logs/status/context), store key findings in working memory, then decide to retry or pivot. Ask the user before irreversible operations.
- Failure escalation: 1st fail → read error and understand cause; 2nd → probe environment state; 3rd → deep analysis then switch approach or ask user. Never repeat an action without new information.

View File

@@ -609,201 +609,455 @@ if sys.platform == 'darwin':
self.frame_idx = 0
# ============================================================================
# Windows Implementation - tkinter with transparentcolor
# Windows/Linux Implementations
# ============================================================================
else:
import tkinter as tk
from PIL import ImageTk
if sys.platform.startswith('win'):
import tkinter as tk
from PIL import ImageTk
class WinPet(PetBase):
def __init__(self, skin_name=None):
self.root = tk.Tk()
self.root.wm_attributes('-topmost', True)
class WinPet(PetBase):
def __init__(self, skin_name=None):
self.root = tk.Tk()
self.root.wm_attributes('-topmost', True)
self.is_windows = sys.platform.startswith('win')
self.platform_name = 'Windows' if self.is_windows else 'Linux'
self.pet_bg_color = '#F0F0F0' if self.is_windows else 'black'
self.toast_bg_color = '#00ff01' if self.is_windows else 'black'
# Load skin
self.load_skin(skin_name)
# Load skin
self.load_skin(skin_name)
# Setup window
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
# Setup window
screen_width = self.root.winfo_screenwidth()
screen_height = self.root.winfo_screenheight()
x_pos = screen_width - 200
y_pos = screen_height - 300
x_pos = screen_width - 200
y_pos = screen_height - 300
self.root.geometry(f'{self.display_width}x{self.display_height}+{x_pos}+{y_pos}')
self.root.overrideredirect(True)
self.root.wm_attributes('-topmost', True)
self.root.geometry(f'{self.display_width}x{self.display_height}+{x_pos}+{y_pos}')
self.root.overrideredirect(True)
self.root.wm_attributes('-topmost', True)
# Transparent background
self.root.wm_attributes('-transparentcolor', '#F0F0F0')
self.root.config(bg='#F0F0F0')
# Transparent background
if self.is_windows:
self.root.wm_attributes('-transparentcolor', self.pet_bg_color)
self.root.config(bg=self.pet_bg_color)
# Create label
self.label = tk.Label(self.root, bg='#F0F0F0', bd=0)
self.label.pack()
# Create label
self.label = tk.Label(self.root, bg=self.pet_bg_color, bd=0)
self.label.pack()
# Bind events
self.label.bind('<Button-1>', lambda e: setattr(self, '_d', (e.x, e.y)))
self.label.bind('<B1-Motion>', self._drag)
self.label.bind('<Double-1>', lambda e: (self.root.destroy(), os._exit(0)))
self.label.bind('<Button-3>', self._on_right_click)
# Bind events
self.label.bind('<Button-1>', lambda e: setattr(self, '_d', (e.x, e.y)))
self.label.bind('<B1-Motion>', self._drag)
self.label.bind('<Double-1>', lambda e: (self.root.destroy(), os._exit(0)))
self.label.bind('<Button-3>', self._on_right_click)
# Animation state
self.current_state = 'idle'
self.frame_idx = 0
# Toast state
self.toast_window = None
self.toast_photo = None
# Start animation
self._animate()
self._start_server()
print(f"✓ Windows Pet started at ({x_pos}, {y_pos})")
print(f" Animations: {', '.join(self.animations.keys())}")
self.root.mainloop()
def load_skin(self, skin_name=None):
"""Load skin configuration and animations"""
available_skins = SkinLoader.list_skins()
if not available_skins:
raise FileNotFoundError(f"No skins found in {SKINS_DIR}")
if skin_name is None or skin_name not in available_skins:
skin_name = available_skins[0]
skin_path = os.path.join(SKINS_DIR, skin_name)
self.skin_config = SkinLoader.load_skin(skin_path)
# Get display size
display_size = self.skin_config.get('size', {})
self.display_width = display_size.get('width', 128)
self.display_height = display_size.get('height', 128)
# Load animations
self.animations = {}
for anim_name, anim_config in self.skin_config['animations'].items():
pil_frames = AnimationLoader.load_sprite_frames(skin_path, anim_config)
# Scale and convert frames
tk_frames = []
for frame in pil_frames:
if frame.mode != 'RGBA':
frame = frame.convert('RGBA')
scaled = frame.resize((self.display_width, self.display_height), Image.NEAREST)
tk_frames.append(ImageTk.PhotoImage(scaled))
self.animations[anim_name] = {
'frames': tk_frames,
'fps': anim_config.get('sprite', {}).get('fps', 6)
}
def set_state(self, state):
"""Change animation state"""
if state in self.animations and state != self.current_state:
self.current_state = state
# Animation state
self.current_state = 'idle'
self.frame_idx = 0
print(f"→ State: {state}")
def _drag(self, e):
x = self.root.winfo_x() + e.x - self._d[0]
y = self.root.winfo_y() + e.y - self._d[1]
self.root.geometry(f'+{x}+{y}')
# Toast state
self.toast_window = None
self.toast_photo = None
def _animate(self):
"""Animate current state"""
if self.current_state not in self.animations:
self.root.after(100, self._animate)
return
# Start animation
self._animate()
self._start_server()
anim = self.animations[self.current_state]
frames = anim['frames']
print(f"{self.platform_name} Pet started at ({x_pos}, {y_pos})")
print(f" Animations: {', '.join(self.animations.keys())}")
if frames:
self.label.config(image=frames[self.frame_idx])
self.root.mainloop()
def load_skin(self, skin_name=None):
"""Load skin configuration and animations"""
available_skins = SkinLoader.list_skins()
if not available_skins:
raise FileNotFoundError(f"No skins found in {SKINS_DIR}")
if skin_name is None or skin_name not in available_skins:
skin_name = available_skins[0]
skin_path = os.path.join(SKINS_DIR, skin_name)
self.skin_config = SkinLoader.load_skin(skin_path)
# Get display size
display_size = self.skin_config.get('size', {})
self.display_width = display_size.get('width', 128)
self.display_height = display_size.get('height', 128)
# Load animations
self.animations = {}
for anim_name, anim_config in self.skin_config['animations'].items():
pil_frames = AnimationLoader.load_sprite_frames(skin_path, anim_config)
# Scale and convert frames
tk_frames = []
for frame in pil_frames:
if frame.mode != 'RGBA':
frame = frame.convert('RGBA')
scaled = frame.resize((self.display_width, self.display_height), Image.NEAREST)
tk_frames.append(ImageTk.PhotoImage(scaled))
self.animations[anim_name] = {
'frames': tk_frames,
'fps': anim_config.get('sprite', {}).get('fps', 6)
}
def set_state(self, state):
"""Change animation state"""
if state in self.animations and state != self.current_state:
self.current_state = state
self.frame_idx = 0
print(f"→ State: {state}")
def _drag(self, e):
x = self.root.winfo_x() + e.x - self._d[0]
y = self.root.winfo_y() + e.y - self._d[1]
self.root.geometry(f'+{x}+{y}')
def _animate(self):
"""Animate current state"""
if self.current_state not in self.animations:
self.root.after(100, self._animate)
return
anim = self.animations[self.current_state]
frames = anim['frames']
if frames:
self.label.config(image=frames[self.frame_idx])
self.frame_idx = (self.frame_idx + 1) % len(frames)
delay = int(1000 / anim['fps'])
self.root.after(delay, self._animate)
def show_toast(self, message):
"""Show toast message above pet"""
if self.toast_window:
try:
self.toast_window.destroy()
except:
pass
self.toast_window = None
bubble_info = build_bubble_image(message, max_width=max(180, min(260, self.display_width * 2)))
bubble_pil = bubble_info['image']
bubble_width, bubble_height = bubble_info['size']
tail_x, tail_y = bubble_info['tail_tip']
self.toast_photo = ImageTk.PhotoImage(bubble_pil)
self.toast_window = tk.Toplevel(self.root)
self.toast_window.overrideredirect(True)
self.toast_window.wm_attributes('-topmost', True)
if self.is_windows:
self.toast_window.wm_attributes('-transparentcolor', self.toast_bg_color)
self.toast_window.config(bg=self.toast_bg_color)
toast_label = tk.Label(
self.toast_window,
image=self.toast_photo,
bg=self.toast_bg_color,
bd=0,
highlightthickness=0
)
toast_label.pack()
pet_x = self.root.winfo_x()
pet_y = self.root.winfo_y()
anchor_x = pet_x + int(self.display_width * 0.75)
anchor_y = pet_y
toast_x = anchor_x - tail_x
toast_y = anchor_y - bubble_height
self.toast_window.geometry(f'{bubble_width}x{bubble_height}+{toast_x}+{toast_y}')
self.root.after(3000, self._hide_toast)
print(f"Toast: {message}")
def _hide_toast(self):
"""Hide toast message"""
if self.toast_window:
try:
self.toast_window.destroy()
self.toast_window = None
except:
pass
def _schedule_main(self, fn):
self.root.after(0, fn)
def run(self):
"""Run the application (already in mainloop)"""
pass
def _on_right_click(self, event):
# Build a dynamic menu of all available skins
menu = tk.Menu(self.root, tearoff=0)
for skin_name in SkinLoader.list_skins():
menu.add_command(
label=skin_name,
command=lambda name=skin_name: self._change_skin(name)
)
menu.add_separator()
menu.add_command(label="Quit", command=lambda: (self.root.destroy(), os._exit(0)))
menu.tk_popup(event.x_root, event.y_root)
def _change_skin(self, skin_name):
print(f"Changing skin to: {skin_name}")
self.load_skin(skin_name)
self.current_state = 'idle'
self.frame_idx = 0
else:
from PySide6.QtCore import Qt, QTimer, QPoint
from PySide6.QtGui import QAction, QCursor, QImage, QPixmap
from PySide6.QtWidgets import QApplication, QLabel, QMenu, QWidget
class _LinuxPetLabel(QLabel):
def __init__(self, pet):
super().__init__()
self.pet = pet
self.drag_offset = None
def mousePressEvent(self, event):
if event.button() == Qt.LeftButton:
self.drag_offset = event.globalPosition().toPoint() - self.pet.window.frameGeometry().topLeft()
event.accept()
return
if event.button() == Qt.RightButton:
self.pet._show_context_menu(event.globalPosition().toPoint())
event.accept()
return
super().mousePressEvent(event)
def mouseMoveEvent(self, event):
if self.drag_offset is not None and (event.buttons() & Qt.LeftButton):
self.pet.window.move(event.globalPosition().toPoint() - self.drag_offset)
self.pet._reposition_toast()
event.accept()
return
super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event):
if event.button() == Qt.LeftButton:
self.drag_offset = None
super().mouseReleaseEvent(event)
def mouseDoubleClickEvent(self, event):
if event.button() == Qt.LeftButton:
QApplication.instance().quit()
event.accept()
return
super().mouseDoubleClickEvent(event)
class LinuxPet(PetBase):
def __init__(self, skin_name=None):
self.app = QApplication.instance() or QApplication(sys.argv)
self.available_skins = SkinLoader.list_skins()
self.load_skin(skin_name)
screen = self.app.primaryScreen()
screen_geo = screen.availableGeometry() if screen else None
if screen_geo:
x_pos = screen_geo.right() - self.display_width - 72
y_pos = screen_geo.bottom() - self.display_height - 120
else:
x_pos, y_pos = 1200, 700
self.window = QWidget()
self.window.setWindowFlags(
Qt.FramelessWindowHint |
Qt.WindowStaysOnTopHint |
Qt.Tool
)
self.window.setAttribute(Qt.WA_TranslucentBackground, True)
self.window.setAttribute(Qt.WA_ShowWithoutActivating, True)
self.window.resize(self.display_width, self.display_height)
self.window.move(x_pos, y_pos)
self.label = _LinuxPetLabel(self)
self.label.setParent(self.window)
self.label.setGeometry(0, 0, self.display_width, self.display_height)
self.label.setAttribute(Qt.WA_TranslucentBackground, True)
self.label.setStyleSheet('background: transparent;')
self.label.setScaledContents(True)
self.current_state = 'idle'
self.frame_idx = 0
self.toast_window = None
self.toast_label = None
self.toast_pixmap = None
self.anim_timer = QTimer()
self.anim_timer.timeout.connect(self._animate)
self._restart_animation_timer()
self.window.show()
self._start_server()
print(f"✓ Linux PySide6 Pet started at ({x_pos}, {y_pos})")
print(f" Animations: {', '.join(self.animations.keys())}")
def _pil_to_qpixmap(self, pil_img):
buffer = io.BytesIO()
pil_img.save(buffer, format='PNG')
qimage = QImage.fromData(buffer.getvalue(), 'PNG')
return QPixmap.fromImage(qimage)
def load_skin(self, skin_name=None):
available_skins = SkinLoader.list_skins()
if not available_skins:
raise FileNotFoundError(f"No skins found in {SKINS_DIR}")
if skin_name is None or skin_name not in available_skins:
skin_name = available_skins[0]
skin_path = os.path.join(SKINS_DIR, skin_name)
self.skin_config = SkinLoader.load_skin(skin_path)
display_size = self.skin_config.get('size', {})
self.display_width = display_size.get('width', 128)
self.display_height = display_size.get('height', 128)
self.animations = {}
for anim_name, anim_config in self.skin_config['animations'].items():
pil_frames = AnimationLoader.load_sprite_frames(skin_path, anim_config)
qt_frames = []
for frame in pil_frames:
if frame.mode != 'RGBA':
frame = frame.convert('RGBA')
scaled = frame.resize((self.display_width, self.display_height), Image.NEAREST)
qt_frames.append(self._pil_to_qpixmap(scaled))
self.animations[anim_name] = {
'frames': qt_frames,
'fps': anim_config.get('sprite', {}).get('fps', 6)
}
if hasattr(self, 'window'):
self.window.resize(self.display_width, self.display_height)
self.label.setGeometry(0, 0, self.display_width, self.display_height)
self._animate(force=True)
self._reposition_toast()
def _restart_animation_timer(self):
anim = self.animations.get(self.current_state) or next(iter(self.animations.values()))
fps = max(1, anim.get('fps', 6))
self.anim_timer.start(int(1000 / fps))
def _animate(self, force=False):
if self.current_state not in self.animations:
return
anim = self.animations[self.current_state]
frames = anim['frames']
if not frames:
return
if force:
self.frame_idx = 0
self.label.setPixmap(frames[self.frame_idx])
self.frame_idx = (self.frame_idx + 1) % len(frames)
delay = int(1000 / anim['fps'])
self.root.after(delay, self._animate)
def set_state(self, state):
if state in self.animations and state != self.current_state:
self.current_state = state
self.frame_idx = 0
self._restart_animation_timer()
print(f"→ State: {state}")
def show_toast(self, message):
"""Show toast message above pet"""
if self.toast_window:
try:
self.toast_window.destroy()
except:
pass
self.toast_window = None
def _show_context_menu(self, global_pos):
menu = QMenu(self.window)
for skin_name in SkinLoader.list_skins():
action = QAction(skin_name, menu)
action.triggered.connect(lambda checked=False, name=skin_name: self._change_skin(name))
menu.addAction(action)
menu.addSeparator()
quit_action = QAction('Quit', menu)
quit_action.triggered.connect(QApplication.instance().quit)
menu.addAction(quit_action)
menu.popup(global_pos)
bubble_info = build_bubble_image(message, max_width=max(180, min(260, self.display_width * 2)))
bubble_pil = bubble_info['image']
bubble_width, bubble_height = bubble_info['size']
tail_x, tail_y = bubble_info['tail_tip']
def _compute_toast_geometry(self, bubble_width, bubble_height, tail_x, tail_y):
pet_pos = self.window.frameGeometry().topLeft()
anchor_x = pet_pos.x() + int(self.display_width * 0.75)
anchor_y = pet_pos.y() + int(self.display_height * 0.15)
return anchor_x - tail_x, anchor_y - tail_y - bubble_height // 2
self.toast_photo = ImageTk.PhotoImage(bubble_pil)
self.toast_window = tk.Toplevel(self.root)
self.toast_window.overrideredirect(True)
self.toast_window.wm_attributes('-topmost', True)
self.toast_window.wm_attributes('-transparentcolor', '#00ff01')
self.toast_window.config(bg='#00ff01')
toast_label = tk.Label(
self.toast_window,
image=self.toast_photo,
bg='#00ff01',
bd=0,
highlightthickness=0
)
toast_label.pack()
pet_x = self.root.winfo_x()
pet_y = self.root.winfo_y()
anchor_x = pet_x + int(self.display_width * 0.75)
anchor_y = pet_y
toast_x = anchor_x - tail_x
toast_y = anchor_y - bubble_height
self.toast_window.geometry(f'{bubble_width}x{bubble_height}+{toast_x}+{toast_y}')
self.root.after(3000, self._hide_toast)
print(f"Toast: {message}")
def _hide_toast(self):
"""Hide toast message"""
if self.toast_window:
try:
self.toast_window.destroy()
def show_toast(self, message):
if self.toast_window:
self.toast_window.close()
self.toast_window = None
except:
pass
self.toast_label = None
self.toast_pixmap = None
def _schedule_main(self, fn):
self.root.after(0, fn)
bubble_info = build_bubble_image(message, max_width=max(180, min(260, self.display_width * 2)))
bubble_pil = bubble_info['image']
bubble_width, bubble_height = bubble_info['size']
tail_x, tail_y = bubble_info['tail_tip']
self.toast_pixmap = self._pil_to_qpixmap(bubble_pil)
def run(self):
"""Run the application (already in mainloop)"""
pass
def _on_right_click(self, event):
# Build a dynamic menu of all available skins
menu = tk.Menu(self.root, tearoff=0)
for skin_name in SkinLoader.list_skins():
menu.add_command(
label=skin_name,
command=lambda name=skin_name: self._change_skin(name)
self.toast_window = QWidget()
self.toast_window.setWindowFlags(
Qt.FramelessWindowHint |
Qt.WindowStaysOnTopHint |
Qt.Tool |
Qt.WindowTransparentForInput
)
menu.add_separator()
menu.add_command(label="Quit", command=lambda: (self.root.destroy(), os._exit(0)))
menu.tk_popup(event.x_root, event.y_root)
self.toast_window.setAttribute(Qt.WA_TranslucentBackground, True)
self.toast_window.setAttribute(Qt.WA_ShowWithoutActivating, True)
self.toast_window.resize(bubble_width, bubble_height)
def _change_skin(self, skin_name):
print(f"Changing skin to: {skin_name}")
self.load_skin(skin_name)
self.current_state = 'idle'
self.frame_idx = 0
self.toast_label = QLabel(self.toast_window)
self.toast_label.setGeometry(0, 0, bubble_width, bubble_height)
self.toast_label.setPixmap(self.toast_pixmap)
self.toast_label.setAttribute(Qt.WA_TranslucentBackground, True)
self.toast_label.setStyleSheet('background: transparent;')
toast_x, toast_y = self._compute_toast_geometry(bubble_width, bubble_height, tail_x, tail_y)
self.toast_window.move(toast_x, toast_y)
self.toast_window.show()
QTimer.singleShot(3000, self._hide_toast)
print(f"Toast: {message}")
def _reposition_toast(self):
if not self.toast_window:
return
label_pixmap = self.toast_label.pixmap() if self.toast_label else None
if label_pixmap is None:
return
bubble_width = label_pixmap.width()
bubble_height = label_pixmap.height()
toast_x, toast_y = self._compute_toast_geometry(
bubble_width,
bubble_height,
bubble_width // 2,
bubble_height
)
self.toast_window.move(toast_x, toast_y)
def _hide_toast(self):
if self.toast_window:
self.toast_window.close()
self.toast_window = None
self.toast_label = None
self.toast_pixmap = None
def _schedule_main(self, fn):
QTimer.singleShot(0, fn)
def _change_skin(self, skin_name):
print(f"Changing skin to: {skin_name}")
self.load_skin(skin_name)
self.current_state = 'idle'
self.frame_idx = 0
self._restart_animation_timer()
def run(self):
self.app.exec()
if __name__ == '__main__':
# Singleton: if port already in use, another instance is running
@@ -820,5 +1074,9 @@ if __name__ == '__main__':
if sys.platform == 'darwin':
pet = MacPet('vita')
pet.run()
else:
elif sys.platform.startswith('win'):
pet = WinPet('vita')
else:
pet = LinuxPet('vita')
pet.run()

View File

@@ -23,7 +23,7 @@ st.set_page_config(page_title="Cowork", layout="wide")
def init():
agent = GeneraticAgent()
if agent.llmclient is None:
st.error("⚠️ 未配置任何可用的 LLM 接口请设置mykey.py。")
st.error("⚠️ 사용 가능한 LLM 인터페이스가 구성되지 않았습니다. mykey.py를 설정하세요.")
st.stop()
else: threading.Thread(target=agent.run, daemon=True).start()
return agent
@@ -37,23 +37,23 @@ if 'autonomous_enabled' not in st.session_state: st.session_state.autonomous_ena
@st.fragment
def render_sidebar():
current_idx = agent.llm_no
st.caption(f"LLM Core: {current_idx}: {agent.get_llm_name()}", help="点击切换备用链路")
st.caption(f"LLM Core: {current_idx}: {agent.get_llm_name()}", help="백업 링크 전환하려면 클릭하세요")
last_reply_time = st.session_state.get('last_reply_time', 0)
if last_reply_time > 0:
st.caption(f"空闲时间{int(time.time()) - last_reply_time}", help="当超过30分钟未收到回复时系统会自动任务")
if st.button("切换备用链路"):
st.caption(f"대기 시간{int(time.time()) - last_reply_time}", help="30분 이상 응답이 없으면 시스템이 자동으로 작업을 시작합니다")
if st.button("백업 링크 전환"):
agent.next_llm(); st.rerun(scope="fragment")
if st.button("强行停止任务"):
agent.abort(); st.toast("已发送停止信号"); st.rerun()
if st.button("重新注入工具"):
if st.button("작업 강제 중지"):
agent.abort(); st.toast("중지 신호가 전송되었습니다"); st.rerun()
if st.button("도구 재주입"):
agent.llmclient.last_tools = ''
try:
hist_path = os.path.join(script_dir, '..', 'assets', 'tool_usable_history.json')
with open(hist_path, 'r', encoding='utf-8') as f: tool_hist = json.load(f)
agent.llmclient.backend.history.extend(tool_hist)
st.toast(f"已重新注入工具,追加了 {len(tool_hist)} 条示范记录")
except Exception as e: st.toast(f"注入工具示范失败: {e}")
if st.button("🐱 桌面宠物"):
st.toast(f"도구가 재주입되었습니다. {len(tool_hist)}개의 예시 기록이 추가되었습니다")
except Exception as e: st.toast(f"도구 예시 주입 실패: {e}")
if st.button("🐱 데스크톱 펫"):
kwargs = {'creationflags': 0x08} if sys.platform == 'win32' else {}
pet_script = os.path.join(script_dir, 'desktop_pet_v2.pyw')
if not os.path.exists(pet_script): pet_script = os.path.join(script_dir, 'desktop_pet.pyw')
@@ -68,31 +68,35 @@ def render_sidebar():
def _pet_hook(ctx):
parts = [f"Turn {ctx.get('turn','?')}"]
if ctx.get('summary'): parts.append(ctx['summary'])
if ctx.get('exit_reason'): parts.append('任务已完成')
if ctx.get('exit_reason'): parts.append('작업 완료')
_pet_req(f'msg={quote(chr(10).join(parts))}')
if ctx.get('exit_reason'): _pet_req('state=idle')
agent._turn_end_hooks['pet'] = _pet_hook
st.toast("桌面宠物已启动")
st.toast("데스크톱 펫이 시작되었습니다")
st.divider()
if st.button("开始空闲自主行动"):
if st.button("대기 중 자율 행동 시작"):
st.session_state.last_reply_time = int(time.time()) - 1800
st.toast("已将上次回复时间设为1800秒前"); st.rerun()
st.toast("마지막 응답 시간을 1800초 전으로 설정했습니다"); st.rerun()
if st.session_state.autonomous_enabled:
if st.button("⏸️ 禁止自主行动"):
if st.button("⏸️ 자율 행동 금지"):
st.session_state.autonomous_enabled = False
st.toast("⏸️ 已禁止自主行动"); st.rerun()
st.caption("🟢 自主行动运行中会在你离开它30分钟后自动进行")
st.toast("⏸️ 자율 행동이 금지되었습니다"); st.rerun()
st.caption("🟢 자율 행동이 실행 중입니다. 30분 후 자동으로 진행됩니다")
else:
if st.button("▶️ 允许自主行动", type="primary"):
if st.button("▶️ 자율 행동 허용", type="primary"):
st.session_state.autonomous_enabled = True
st.toast("已允许自主行动"); st.rerun()
st.caption("🔴 自主行动已停止")
st.toast("자율 행동이 허용되었습니다"); st.rerun()
st.caption("🔴 자율 행동이 중지되었습니다")
with st.sidebar: render_sidebar()
def fold_turns(text):
"""Return list of segments: [{'type':'text','content':...}, {'type':'fold','title':...,'content':...}]"""
parts = re.split(r'(\**LLM Running \(Turn \d+\) \.\.\.\*\**)', text)
# 먼저 4개 이상의 백틱 블록을 플레이스홀더로 교체하여 하위 에이전트 중첩 LLM Running이 잘못 잘리는 것을 방지합니다.
_ph = []
safe = re.sub(r'`{4,}.*?`{4,}', lambda m: (_ph.append(m.group(0)), f'\x00PH{len(_ph)-1}\x00')[1], text, flags=re.DOTALL)
parts = re.split(r'(\**LLM Running \(Turn \d+\) \.\.\.\*\**)', safe)
parts = [re.sub(r'\x00PH(\d+)\x00', lambda m: _ph[int(m.group(1))], p) for p in parts]
if len(parts) < 4: return [{'type': 'text', 'content': text}]
segments = []
if parts[0].strip(): segments.append({'type': 'text', 'content': parts[0]})
@@ -103,7 +107,7 @@ def fold_turns(text):
turns.append((marker, content))
for idx, (marker, content) in enumerate(turns):
if idx < len(turns) - 1:
_c = re.sub(r'```.*?```|<thinking>.*?</thinking>', '', content, flags=re.DOTALL)
_c = re.sub(r'`{3,}.*?`{3,}|<thinking>.*?</thinking>', '', content, flags=re.DOTALL)
matches = re.findall(r'<summary>\s*((?:(?!<summary>).)*?)\s*</summary>', _c, re.DOTALL)
if matches:
title = matches[0].strip()
@@ -114,9 +118,9 @@ def fold_turns(text):
else: segments.append({'type': 'text', 'content': marker + content})
return segments
def render_segments(segments, suffix=''):
# 整块重画:调用方用 slot.container() 包裹,保证 DOM 路径稳定、跨 rerun 对齐(消除"灰色重影")。
# heartbeat 空转时 segments 不变 → Streamlit 后端 diff 无变化 → 前端零闪烁;
# container/markdown 本身是 API 调用,StopException 仍会被抛出abort 照常起作用)。
# 전체 다시 그리기: 호출자가 slot.container()로 감싸서 DOM 경로가 안정적이고 rerun 간 정렬이 보장되도록 함 ("회색 잔상" 제거).
# heartbeat 대기 시 segments 변경 없음 → Streamlit 백엔드 diff 변화 없음 → 프론트엔드 깜빡임 제로;
# container/markdown 자체는 API 호출이므로 StopException은 여전히 발생함 (abort는 정상 작동).
for seg in segments:
if seg['type'] == 'fold':
with st.expander(seg['title'], expanded=False): st.markdown(seg['content'])
@@ -141,7 +145,7 @@ def agent_backend_stream(prompt):
if "messages" not in st.session_state: st.session_state.messages = []
for msg in st.session_state.messages:
with st.chat_message(msg["role"]):
# slot=st.empty() + with slot.container(): ... 的外壳DOM 路径和流式渲染完全一致,跨 rerun 对齐
# slot=st.empty() + with slot.container(): ... 외부 껍질을 사용하여 DOM 경로와 스트리밍 렌더링이 완전히 일치하고 rerun 간 정렬됨
slot = st.empty()
with slot.container():
if msg["role"] == "assistant": render_segments(fold_turns(msg["content"]))
@@ -217,7 +221,7 @@ if prompt := st.chat_input("any task?"):
while frozen < n_done:
with live.container(): render_segments([segs[frozen]])
live = st.empty(); frozen += 1
with live.container(): render_segments([segs[-1]], suffix=CURSOR) # live 区域
with live.container(): render_segments([segs[-1]], suffix=CURSOR) # 라이브 영역
segs = fold_turns(response)
for i in range(frozen, len(segs)):
with live.container(): render_segments([segs[i]])

View File

@@ -1,4 +1,4 @@
import os, sys, re, threading, asyncio, queue as Q, socket, time, random
import os, sys, re, threading, asyncio, queue as Q, time, random
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
_TEMP_DIR = os.path.join(os.path.dirname(os.path.dirname(os.path.abspath(__file__))), 'temp')
from agentmain import GeneraticAgent
@@ -16,10 +16,12 @@ from chatapp_common import (
HELP_TEXT,
TELEGRAM_MENU_COMMANDS,
clean_reply,
ensure_single_instance,
extract_files,
format_restore,
redirect_log,
require_runtime,
split_text,
strip_files,
)
from continue_cmd import handle_frontend_command, reset_conversation
from llmcore import mykeys
@@ -38,6 +40,8 @@ _MD_TOKEN_RE = re.compile(
r"|\[([^\]]+)\]\(([^)\n]+)\)"
r"|`([^`\n]+)`"
r"|\*\*([^\n]+?)\*\*"
r"|__([^\n]+?)__"
r"|~~([^\n]+?)~~"
r"|(?<!\*)\*(?!\*)([^\n]+?)(?<!\*)\*(?!\*)",
re.DOTALL,
)
@@ -61,6 +65,11 @@ def _resolve_files(paths):
return files
def _render_file_markers(text):
def repl(match):
return os.path.basename(match.group(1))
return re.sub(r"\[FILE:([^\]]+)\]", repl, text or "").strip()
def _escape_pre(text):
return escape_markdown(text or "", version=2, entity_type="pre")
@@ -90,7 +99,11 @@ def _to_markdown_v2(text):
elif match.group(7) is not None:
parts.append(f"*{escape_markdown(match.group(7), version=2)}*")
elif match.group(8) is not None:
parts.append(f"_{escape_markdown(match.group(8), version=2)}_")
parts.append(f"*{escape_markdown(match.group(8), version=2)}*")
elif match.group(9) is not None:
parts.append(f"~{escape_markdown(match.group(9), version=2)}~")
elif match.group(10) is not None:
parts.append(f"_{escape_markdown(match.group(10), version=2)}_")
pos = match.end()
parts.append(escape_markdown(text[pos:], version=2))
return "".join(parts)
@@ -102,7 +115,7 @@ class _TelegramStreamSession:
def __init__(self, root_msg):
self.root_msg = root_msg
self.private_chat = getattr(getattr(root_msg, "chat", None), "type", "") == ChatType.PRIVATE
self.can_use_draft = self.private_chat
self.can_use_draft = False # can not use or streaming dead
self.draft_id = _make_draft_id()
self.live_msg = None
self.raw_text = ""
@@ -144,7 +157,7 @@ class _TelegramStreamSession:
async def _refresh(self, done, send_files):
cleaned = clean_reply(self.raw_text) if self.raw_text.strip() else ""
self.files = _resolve_files(extract_files(cleaned))
body = strip_files(cleaned)
body = _render_file_markers(cleaned)
if done and not body and self.files:
body = "已生成附件"
elif done and not body:
@@ -244,16 +257,12 @@ async def _stream(dq, msg):
await session.prime()
try:
while True:
try:
first = await asyncio.to_thread(dq.get, True, _QUEUE_WAIT_SECONDS)
except Q.Empty:
continue
try: first = await asyncio.to_thread(dq.get, True, _QUEUE_WAIT_SECONDS)
except Q.Empty: continue
items = [first]
try:
while True:
items.append(dq.get_nowait())
except Q.Empty:
pass
while True: items.append(dq.get_nowait())
except Q.Empty: pass
done_item = next((item for item in items if "done" in item), None)
if done_item is not None:
await session.finalize(done_item.get("done", ""))
@@ -267,20 +276,16 @@ async def _stream(dq, msg):
print(f"[TG stream error] {type(exc).__name__}: {exc}", flush=True)
await session.finish_with_notice(f"❌ 输出失败: {exc}")
def _normalized_command(text):
parts = (text or "").strip().split(None, 1)
if not parts:
return ''
if not parts: return ''
head = parts[0].lower()
if head.startswith('/'):
head = '/' + head[1:].split('@', 1)[0]
if head.startswith('/'): head = '/' + head[1:].split('@', 1)[0]
return head + (f" {parts[1].strip()}" if len(parts) > 1 and parts[1].strip() else '')
def _cancel_stream_task(ctx):
task = ctx.user_data.pop('stream_task', None)
if task and not task.done():
task.cancel()
if task and not task.done(): task.cancel()
async def _sync_commands(application):
await application.bot.set_my_commands([BotCommand(command, description) for command, description in TELEGRAM_MENU_COMMANDS])
@@ -314,14 +319,22 @@ async def cmd_llm(update, ctx):
async def handle_photo(update, ctx):
uid = update.effective_user.id
if ALLOWED and uid not in ALLOWED:
return await update.message.reply_text("no")
photo = update.message.photo[-1]
file = await photo.get_file()
fpath = f"tg_{photo.file_unique_id}.jpg"
if ALLOWED and uid not in ALLOWED: return await update.message.reply_text("no")
if update.message.photo:
photo = update.message.photo[-1]
file = await photo.get_file()
fpath = f"tg_{photo.file_unique_id}.jpg"
kind = "图片"
elif update.message.document:
doc = update.message.document
file = await doc.get_file()
ext = os.path.splitext(doc.file_name or '')[1] or ''
fpath = f"tg_{doc.file_unique_id}{ext}"
kind = "文件"
else: return
await file.download_to_drive(os.path.join(_TEMP_DIR, fpath))
caption = update.message.caption
prompt = f"[TIPS] 收到图片temp/{fpath}\n{caption}" if caption else f"[TIPS] 收到图片temp/{fpath},请等待下一步指令"
prompt = f"[TIPS] 收到{kind}temp/{fpath}\n{caption}" if caption else f"[TIPS] 收到{kind}temp/{fpath},请等待下一步指令"
dq = agent.put_task(prompt, source="telegram")
task = asyncio.create_task(_stream(dq, update.message))
ctx.user_data['stream_task'] = task
@@ -359,20 +372,18 @@ async def handle_command(update, ctx):
return await update.message.reply_text(HELP_TEXT)
if __name__ == '__main__':
try: # Single instance lock using socket
_lock_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM); _lock_sock.bind(('127.0.0.1', 19527))
except OSError:
print('[Telegram] Another instance is already running, skiping...')
sys.exit(1)
_LOCK_SOCK = ensure_single_instance(19527, "Telegram")
if not ALLOWED:
print('[Telegram] ERROR: tg_allowed_users in mykey.py is empty or missing. Set it to avoid unauthorized access.')
sys.exit(1)
_logf = open(os.path.join(os.path.dirname(os.path.dirname(__file__)), 'temp', 'tgapp.log'), 'a', encoding='utf-8', buffering=1)
sys.stdout = sys.stderr = _logf
print('[NEW] New process starting, the above are history infos ...')
require_runtime(agent, "Telegram", tg_bot_token=mykeys.get("tg_bot_token"))
redirect_log(__file__, "tgapp.log", "Telegram", ALLOWED)
threading.Thread(target=agent.run, daemon=True).start()
proxy = mykeys.get('proxy', None) # set 'proxy' in mykey.py if needed, e.g. 'http://127.0.0.1:2082'
print('proxy:', proxy)
proxy = mykeys.get('proxy')
if proxy:
print('proxy:', proxy)
else:
print('proxy: <disabled>')
async def _error_handler(update, context: ContextTypes.DEFAULT_TYPE):
print(f"[{time.strftime('%m-%d %H:%M')}] TG error: {context.error}", flush=True)
@@ -381,11 +392,15 @@ if __name__ == '__main__':
try:
print(f"TG bot starting... {time.strftime('%m-%d %H:%M')}")
# Recreate request and app objects on each restart to avoid stale connections
request = HTTPXRequest(proxy=proxy, read_timeout=30, write_timeout=30, connect_timeout=30, pool_timeout=30)
request_kwargs = dict(read_timeout=30, write_timeout=30, connect_timeout=30, pool_timeout=30)
if proxy:
request_kwargs['proxy'] = proxy
request = HTTPXRequest(**request_kwargs)
app = (ApplicationBuilder().token(mykeys['tg_bot_token'])
.request(request).get_updates_request(request).post_init(_sync_commands).build())
app.add_handler(MessageHandler(filters.COMMAND, handle_command))
app.add_handler(MessageHandler(filters.PHOTO, handle_photo))
app.add_handler(MessageHandler(filters.Document.ALL, handle_photo))
app.add_handler(MessageHandler(filters.TEXT & ~filters.COMMAND, handle_msg))
app.add_error_handler(_error_handler)
app.run_polling(drop_pending_updates=True, poll_interval=1.0, timeout=30)

View File

@@ -289,26 +289,33 @@ def on_message(bot, msg):
dq = agent.put_task(prompt, source="wechat")
try: bot.send_typing(uid)
except: pass
# Wait for completion
result = ''
result = ''; sent = 0; mi = 0; last_turns = 0; last_send = 0
def _wx_send(text):
try: bot.send_text(uid, text.strip(), context_token=ctx); return True
except Exception as e:
print(f'[WX] send maybe-ok: {e}', file=sys.__stdout__); return True
def _flush(show, final=False):
nonlocal sent, mi, last_send
now = time.time()
if mi < 9 and sent < len(show) and (mi == 0 or now - last_send >= 6):
chunk = show[sent:sent+900]; sent += len(chunk); mi += 1
if chunk.strip() and _wx_send(chunk): last_send = time.time()
if final:
rest = (show[sent:] + '\n\n[Info] 任务完成')[-1800:]
if rest.strip(): _wx_send(rest)
try:
while True:
item = dq.get(timeout=300)
if 'done' in item: result = item['done']; break
raw = item.get('next', '')
turns = raw.count('LLM Running')
if turns > last_turns:
last_turns = turns; _flush(_clean(raw))
except queue.Empty: result = '[超时]'
show = _clean(result); _flush(show, final=True)
files = re.findall(r'\[FILE:([^\]]+)\]', result)
bad = {'filepath', '<filepath>', 'path', '<path>', 'file_path', '<file_path>', '...'}
files = [f for f in files if f.strip().lower() not in bad and (f if os.path.isabs(f) else os.path.join(_TEMP_DIR, f)) not in media_paths]
show = _clean(result)
chunks = _split(show)
_MAX_MSGS = 6
if len(chunks) > _MAX_MSGS:
keep = chunks[:3] + [f'...(省略{len(chunks) - 5}条)...'] + chunks[-2:]
chunks = keep
for chunk in chunks:
try: bot.send_text(uid, chunk, context_token=ctx)
except Exception as e: print(f'[WX] send err: {e}', file=sys.__stdout__)
time.sleep(0.3)
for fpath in set(files):
if not os.path.isabs(fpath): fpath = os.path.join(_TEMP_DIR, fpath)
try:

View File

@@ -71,20 +71,20 @@ class WeComApp(AgentChatMixin):
except Exception as e:
print(f"[WeCom] welcome error: {e}")
async def on_connected(self, frame):
async def on_connected(self):
print("[WeCom] connected")
async def on_authenticated(self, frame):
async def on_authenticated(self):
print("[WeCom] authenticated")
async def on_disconnected(self, frame):
print("[WeCom] disconnected")
async def on_disconnected(self, reason=""):
print(f"[WeCom] disconnected: {reason}")
async def on_error(self, frame):
print(f"[WeCom] error: {frame}")
async def on_error(self, error=None):
print(f"[WeCom] error: {error}")
async def start(self):
self.client = WSClient({"bot_id": BOT_ID, "secret": SECRET, "reconnect_interval": 1000, "max_reconnect_attempts": -1, "heartbeat_interval": 30000})
self.client = WSClient(BOT_ID, SECRET, reconnect_interval=1000, max_reconnect_attempts=-1, heartbeat_interval=30000)
for event, handler in {
"connected": self.on_connected,
"authenticated": self.on_authenticated,
@@ -95,7 +95,7 @@ class WeComApp(AgentChatMixin):
}.items():
self.client.on(event, handler)
print("[WeCom] bot starting...")
await self.client.connect_async()
await self.client.connect()
while True:
await asyncio.sleep(1)

9
ga.py
View File

@@ -443,8 +443,17 @@ class GenericAgentHandler(BaseHandler):
content = getattr(response, 'content', '') or ""
thinking = getattr(response, 'thinking', '') or ""
if not response or (not content.strip() and not thinking.strip()):
count = self.working.get('empty_response_count', 0) + 1
self.working['empty_response_count'] = count
if count >= 5:
yield f"[Warn] LLM returned an empty response {count} times. Asking user for confirmation.\n"
return StepOutcome({}, next_prompt="[SYSTEM] LLM has returned empty responses 5 or more times. Please ask the user if they want to continue or stop.")
yield "[Warn] LLM returned an empty response. Retrying...\n"
return StepOutcome({}, next_prompt="[System] Blank response, regenerate and tooluse")
# Reset counter on successful non-empty response
if self.working.get('empty_response_count', 0) > 0:
self.working['empty_response_count'] = 0
if len(content) > 100 and ('未收到完整响应 !!!]' in content[-100:] or '!!!Error: [SSL:' in content[-100:]):
return StepOutcome({}, next_prompt="[System] Incomplete response. Regenerate and tooluse.")
if 'max_tokens !!!]' in content[-100:]:

View File

@@ -18,7 +18,7 @@ def get_screen_width():
def start_streamlit(port):
global proc
cmd = [sys.executable, "-m", "streamlit", "run", os.path.join(frontends_dir, "stapp.py"), "--server.port", str(port), "--server.address", "localhost", "--server.headless", "true"]
cmd = [sys.executable, "-m", "streamlit", "run", os.path.join(frontends_dir, "stapp.py"), "--server.port", str(port), "--server.address", "0.0.0.0", "--server.headless", "true"]
proc = subprocess.Popen(cmd)
atexit.register(proc.kill)

View File

@@ -74,6 +74,12 @@ def _sanitize_leading_user_msg(msg):
msg['content'] = [{"type": "text", "text": '\n'.join(t for t in texts if t)}]
return msg
_oldprint = print
def safeprint(*argv):
try: _oldprint(*argv)
except OSError: pass
print = safeprint
def trim_messages_history(history, context_win):
compress_history_tags(history)
cost = sum(len(json.dumps(m, ensure_ascii=False)) for m in history)
@@ -503,7 +509,7 @@ class BaseSession:
mode = str(cfg.get('api_mode', 'chat_completions')).strip().lower().replace('-', '_')
self.api_mode = 'responses' if mode in ('responses', 'response') else 'chat_completions'
self.temperature = cfg.get('temperature', 1)
self.max_tokens = cfg.get('max_tokens', 8192)
self.max_tokens = cfg.get('max_tokens')
def _apply_claude_thinking(self, payload):
if self.thinking_type:
thinking = {"type": self.thinking_type}
@@ -544,6 +550,7 @@ def _drop_unsigned_thinking(messages):
class ClaudeSession(BaseSession):
def raw_ask(self, messages):
if self.max_tokens is None: self.max_tokens = 8192
headers = {"x-api-key": self.api_key, "Content-Type": "application/json", "anthropic-version": "2023-06-01", "anthropic-beta": "prompt-caching-2024-07-31"}
payload = {"model": self.model, "messages": messages, "max_tokens": self.max_tokens, "stream": True}
if self.temperature != 1: payload["temperature"] = self.temperature
@@ -600,6 +607,7 @@ class NativeClaudeSession(BaseSession):
self.tools = None
def raw_ask(self, messages):
messages = _drop_unsigned_thinking(_fix_messages(messages))
if self.max_tokens is None: self.max_tokens = 8192
model = self.model
beta_parts = ["claude-code-20250219", "interleaved-thinking-2025-05-14", "redact-thinking-2026-02-12", "prompt-caching-scope-2026-01-05"]
if "[1m]" in model.lower():
@@ -936,7 +944,7 @@ class MixinSession:
THINKING_PROMPT_ZH = """
### 行动规范(持续有效)
每次回复先在回复文字中包含一个<summary></summary> 中输出极简单行(<30字物理快照上次结果新信息+本次意图。此内容进入长期工作记忆。
每次回复(含工具调用轮)都先在回复文字中包含一个<summary></summary> 中输出极简单行(<30字物理快照上次结果新信息+本次意图。此内容进入长期工作记忆。
\n**若用户需求未完成,必须进行工具调用!**
""".strip()
THINKING_PROMPT_EN = """

View File

@@ -1,8 +1,10 @@
"""Keychain: save key to a file, then keys.set("name", file="path"); keys.name.use() to retrieve (use but no print)."""
import json, os, hashlib, pathlib
import json, os, hashlib, pathlib, getpass
_PATH = pathlib.Path.home() / "ga_keychain.enc"
_MASK = hashlib.sha256(f"{os.getlogin()}@ga_keychain".encode()).digest()
try: _user = os.getlogin()
except OSError: _user = getpass.getuser()
_MASK = hashlib.sha256(f"{_user}@ga_keychain".encode()).digest()
def _xor(data: bytes) -> bytes:
return bytes(b ^ _MASK[i % len(_MASK)] for i, b in enumerate(data))