Compare commits
24 Commits
afa4586e3d
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
365f4d31d2 | ||
|
|
475c203ea1 | ||
|
|
50a7544a55 | ||
|
|
bf8914856e | ||
|
|
cd0ce4d9b0 | ||
|
|
69f66997fd | ||
|
|
567c7f582b | ||
|
|
adf393c0a5 | ||
|
|
12f23138e0 | ||
|
|
c19ac2dae6 | ||
|
|
5805566d88 | ||
|
|
da91a0643f | ||
|
|
5b0d96b1b5 | ||
|
|
10776fed51 | ||
|
|
bd2dd42ba4 | ||
|
|
0f77470fe0 | ||
|
|
3b93c6137d | ||
|
|
09cf6306af | ||
|
|
936501069c | ||
|
|
8e159a9773 | ||
|
|
0c5d3c8635 | ||
|
|
446d7bf549 | ||
|
|
b34965b936 | ||
|
|
7255515607 |
@@ -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
179
assets/install-macos-app.sh
Executable 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 ""
|
||||
@@ -1,6 +1,6 @@
|
||||
# Role: 物理级全能执行者
|
||||
你拥有文件读写、脚本执行、用户浏览器JS注入、系统级干预的物理操作权限。禁止推诿"无法操作"——不空想,用工具探测。
|
||||
## 行动原则
|
||||
调用工具前在<thinking>内推演:当前阶段、上步结果是否符合预期、下步策略;<summary>内输出极简总结。
|
||||
调用工具前先推演:当前阶段、上步结果是否符合预期、下步策略;回复文本中用<summary>输出极简总结。
|
||||
- 探测优先:失败时先充分获取信息(日志/状态/上下文),关键信息存入工作记忆,再决定重试或换方案。不可逆操作先询问用户。
|
||||
- 失败升级:1次→读错误理解原因,2次→探测环境状态,3次→深度分析后换方案或问用户。禁止无新信息的重复操作。
|
||||
|
||||
@@ -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.
|
||||
@@ -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()
|
||||
|
||||
|
||||
@@ -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]])
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
9
ga.py
@@ -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:]:
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
12
llmcore.py
12
llmcore.py
@@ -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 = """
|
||||
|
||||
@@ -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))
|
||||
|
||||
Reference in New Issue
Block a user