fix: LLM 工具循环 400、中断消息注入、ConPTY 终端支持

- agent: 工具轮请求尾部补 user 占位(zen 网关强制),tool 消息正确配对
- agent: 工具提醒/中断以 system 角色注入并带 [中断消息] 前缀,不进用户履历;系统提示词说明中断消息格式
- agentcli: 基于 ConPTY 的交互式终端(ptywin fork),terminal_create/read/write/resize/close/watch
- webui: server 输出通道适配器(保留 reasoning_content/disable_thinking)
- GUI: 沉浸式标题栏、icon 圆角重制、mascot 等打磨
This commit is contained in:
JianFeeeee
2026-08-14 00:48:40 +08:00
parent 816597caac
commit 147d0baaf9
43 changed files with 4670 additions and 1478 deletions

389
internal/ptywin/conpty.go Normal file
View File

@ -0,0 +1,389 @@
//go:build windows
// +build windows
package ptywin
import (
"context"
"errors"
"fmt"
"unicode/utf16"
"unsafe"
"golang.org/x/sys/windows"
)
var (
modKernel32 = windows.NewLazySystemDLL("kernel32.dll")
fCreatePseudoConsole = modKernel32.NewProc("CreatePseudoConsole")
fResizePseudoConsole = modKernel32.NewProc("ResizePseudoConsole")
fClosePseudoConsole = modKernel32.NewProc("ClosePseudoConsole")
fInitializeProcThreadAttributeList = modKernel32.NewProc("InitializeProcThreadAttributeList")
fUpdateProcThreadAttribute = modKernel32.NewProc("UpdateProcThreadAttribute")
ErrConPtyUnsupported = errors.New("ConPty is not available on this version of Windows")
)
func IsConPtyAvailable() bool {
return fCreatePseudoConsole.Find() == nil &&
fResizePseudoConsole.Find() == nil &&
fClosePseudoConsole.Find() == nil &&
fInitializeProcThreadAttributeList.Find() == nil &&
fUpdateProcThreadAttribute.Find() == nil
}
const (
_STILL_ACTIVE uint32 = 259
_S_OK uintptr = 0
_PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE uintptr = 0x20016
defaultConsoleWidth = 80 // in characters
defaultConsoleHeight = 40 // in characters
)
type _COORD struct {
X, Y int16
}
func (c *_COORD) Pack() uintptr {
return uintptr((int32(c.Y) << 16) | int32(c.X))
}
type _HPCON windows.Handle
type handleIO struct {
handle windows.Handle
}
func (h *handleIO) Read(p []byte) (int, error) {
var numRead uint32 = 0
err := windows.ReadFile(h.handle, p, &numRead, nil)
return int(numRead), err
}
func (h *handleIO) Write(p []byte) (int, error) {
var numWritten uint32 = 0
err := windows.WriteFile(h.handle, p, &numWritten, nil)
return int(numWritten), err
}
func (h *handleIO) Close() error {
return windows.CloseHandle(h.handle)
}
type ConPty struct {
hpc _HPCON
pi *windows.ProcessInformation
ptyIn, ptyOut, cmdIn, cmdOut *handleIO
}
func win32ClosePseudoConsole(hPc _HPCON) {
if fClosePseudoConsole.Find() != nil {
return
}
// this kills the attached process. there is no return value.
fClosePseudoConsole.Call(uintptr(hPc))
}
func win32ResizePseudoConsole(hPc _HPCON, coord *_COORD) error {
if fResizePseudoConsole.Find() != nil {
return fmt.Errorf("ResizePseudoConsole not found")
}
ret, _, _ := fResizePseudoConsole.Call(uintptr(hPc), coord.Pack())
if ret != _S_OK {
return fmt.Errorf("ResizePseudoConsole failed with status 0x%x", ret)
}
return nil
}
func win32CreatePseudoConsole(c *_COORD, hIn, hOut windows.Handle) (_HPCON, error) {
if fCreatePseudoConsole.Find() != nil {
return 0, fmt.Errorf("CreatePseudoConsole not found")
}
var hPc _HPCON
ret, _, _ := fCreatePseudoConsole.Call(
c.Pack(),
uintptr(hIn),
uintptr(hOut),
0,
uintptr(unsafe.Pointer(&hPc)))
if ret != _S_OK {
return 0, fmt.Errorf("CreatePseudoConsole() failed with status 0x%x", ret)
}
return hPc, nil
}
type _StartupInfoEx struct {
startupInfo windows.StartupInfo
attributeList []byte
}
func getStartupInfoExForPTY(hpc _HPCON) (*_StartupInfoEx, error) {
if fInitializeProcThreadAttributeList.Find() != nil {
return nil, fmt.Errorf("InitializeProcThreadAttributeList not found")
}
if fUpdateProcThreadAttribute.Find() != nil {
return nil, fmt.Errorf("UpdateProcThreadAttribute not found")
}
var siEx _StartupInfoEx
siEx.startupInfo.Cb = uint32(unsafe.Sizeof(windows.StartupInfo{}) + unsafe.Sizeof(&siEx.attributeList[0]))
siEx.startupInfo.Flags |= windows.STARTF_USESTDHANDLES
var size uintptr
// first call is to get required size. this should return false.
ret, _, _ := fInitializeProcThreadAttributeList.Call(0, 1, 0, uintptr(unsafe.Pointer(&size)))
siEx.attributeList = make([]byte, size, size)
ret, _, err := fInitializeProcThreadAttributeList.Call(
uintptr(unsafe.Pointer(&siEx.attributeList[0])),
1,
0,
uintptr(unsafe.Pointer(&size)))
if ret != 1 {
return nil, fmt.Errorf("InitializeProcThreadAttributeList: %v", err)
}
ret, _, err = fUpdateProcThreadAttribute.Call(
uintptr(unsafe.Pointer(&siEx.attributeList[0])),
0,
_PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
uintptr(hpc),
unsafe.Sizeof(hpc),
0,
0)
if ret != 1 {
return nil, fmt.Errorf("InitializeProcThreadAttributeList: %v", err)
}
return &siEx, nil
}
func createConsoleProcessAttachedToPTY(hpc _HPCON, commandLine, workDir string, env []string) (*windows.ProcessInformation, error) {
cmdLine, err := windows.UTF16PtrFromString(commandLine)
if err != nil {
return nil, err
}
var currentDirectory *uint16
if workDir != "" {
currentDirectory, err = windows.UTF16PtrFromString(workDir)
if err != nil {
return nil, err
}
}
var envBlock *uint16
flags := uint32(windows.EXTENDED_STARTUPINFO_PRESENT)
if env != nil {
flags |= uint32(windows.CREATE_UNICODE_ENVIRONMENT)
envBlock = createEnvBlock(env)
}
siEx, err := getStartupInfoExForPTY(hpc)
if err != nil {
return nil, err
}
var pi windows.ProcessInformation
err = windows.CreateProcess(
nil, // use this if no args
cmdLine,
nil,
nil,
false, // inheritHandle
flags,
envBlock,
currentDirectory,
&siEx.startupInfo,
&pi)
if err != nil {
return nil, err
}
return &pi, nil
}
// createEnvBlock refers to syscall.createEnvBlock in go/src/syscall/exec_windows.go
// Sourced From: https://github.com/creack/pty/pull/155
func createEnvBlock(envv []string) *uint16 {
if len(envv) == 0 {
return &utf16.Encode([]rune("\x00\x00"))[0]
}
length := 0
for _, s := range envv {
length += len(s) + 1
}
length += 1
b := make([]byte, length)
i := 0
for _, s := range envv {
l := len(s)
copy(b[i:i+l], []byte(s))
copy(b[i+l:i+l+1], []byte{0})
i = i + l + 1
}
copy(b[i:i+1], []byte{0})
return &utf16.Encode([]rune(string(b)))[0]
}
// This will only return the first error.
func closeHandles(handles ...windows.Handle) error {
var err error
for _, h := range handles {
if h != windows.InvalidHandle {
if err == nil {
err = windows.CloseHandle(h)
} else {
windows.CloseHandle(h)
}
}
}
return err
}
// Close all open handles and terminate the process.
func (cpty *ConPty) Close() error {
// there is no return code
win32ClosePseudoConsole(cpty.hpc)
return closeHandles(
cpty.pi.Process,
cpty.pi.Thread,
cpty.ptyIn.handle,
cpty.ptyOut.handle,
cpty.cmdIn.handle,
cpty.cmdOut.handle)
}
// Wait for the process to exit and return the exit code. If context is canceled,
// Wait() will return STILL_ACTIVE and an error indicating the context was canceled.
func (cpty *ConPty) Wait(ctx context.Context) (uint32, error) {
var exitCode uint32 = _STILL_ACTIVE
for {
if err := ctx.Err(); err != nil {
return _STILL_ACTIVE, fmt.Errorf("wait canceled: %v", err)
}
ret, _ := windows.WaitForSingleObject(cpty.pi.Process, 1000)
if ret != uint32(windows.WAIT_TIMEOUT) {
err := windows.GetExitCodeProcess(cpty.pi.Process, &exitCode)
return exitCode, err
}
}
}
func (cpty *ConPty) Resize(width, height int) error {
coords := _COORD{
int16(width),
int16(height),
}
return win32ResizePseudoConsole(cpty.hpc, &coords)
}
func (cpty *ConPty) Read(p []byte) (int, error) {
return cpty.cmdOut.Read(p)
}
func (cpty *ConPty) Write(p []byte) (int, error) {
return cpty.cmdIn.Write(p)
}
func (cpty *ConPty) Pid() int {
return int(cpty.pi.ProcessId)
}
// Running 报告子进程是否仍在运行GetExitCodeProcess == STILL_ACTIVE
func (cpty *ConPty) Running() bool {
if cpty.pi == nil || cpty.pi.Process == 0 {
return false
}
var code uint32
err := windows.GetExitCodeProcess(cpty.pi.Process, &code)
if err != nil {
return false
}
return code == _STILL_ACTIVE
}
// Kill 强制终止子进程并关闭进程/线程句柄。
func (cpty *ConPty) Kill() error {
if cpty.pi == nil || cpty.pi.Process == 0 {
return nil
}
if err := windows.TerminateProcess(cpty.pi.Process, 1); err != nil {
return err
}
windows.CloseHandle(cpty.pi.Process)
windows.CloseHandle(cpty.pi.Thread)
cpty.pi.Process = 0
cpty.pi.Thread = 0
return nil
}
type conPtyArgs struct {
coords _COORD
workDir string
env []string
}
type ConPtyOption func(args *conPtyArgs)
func ConPtyDimensions(width, height int) ConPtyOption {
return func(args *conPtyArgs) {
args.coords.X = int16(width)
args.coords.Y = int16(height)
}
}
func ConPtyWorkDir(workDir string) ConPtyOption {
return func(args *conPtyArgs) {
args.workDir = workDir
}
}
func ConPtyEnv(env []string) ConPtyOption {
return func(args *conPtyArgs) {
args.env = env
}
}
// Start a new process specified in `commandLine` and attach a pseudo console using the Windows
// ConPty API. If ConPty is not available, ErrConPtyUnsupported will be returned.
//
// On successful return, an instance of ConPty is returned. You must call Close() on this to release
// any resources associated with the process. To get the exit code of the process, you can call Wait().
func Start(commandLine string, options ...ConPtyOption) (*ConPty, error) {
if !IsConPtyAvailable() {
return nil, ErrConPtyUnsupported
}
args := &conPtyArgs{
coords: _COORD{defaultConsoleWidth, defaultConsoleHeight},
}
for _, opt := range options {
opt(args)
}
var cmdIn, cmdOut, ptyIn, ptyOut windows.Handle
if err := windows.CreatePipe(&ptyIn, &cmdIn, nil, 0); err != nil {
return nil, fmt.Errorf("CreatePipe: %v", err)
}
if err := windows.CreatePipe(&cmdOut, &ptyOut, nil, 0); err != nil {
closeHandles(ptyIn, cmdIn)
return nil, fmt.Errorf("CreatePipe: %v", err)
}
hPc, err := win32CreatePseudoConsole(&args.coords, ptyIn, ptyOut)
if err != nil {
closeHandles(ptyIn, ptyOut, cmdIn, cmdOut)
return nil, err
}
pi, err := createConsoleProcessAttachedToPTY(hPc, commandLine, args.workDir, args.env)
if err != nil {
closeHandles(ptyIn, ptyOut, cmdIn, cmdOut)
win32ClosePseudoConsole(hPc)
return nil, fmt.Errorf("Failed to create console process: %v", err)
}
cpty := &ConPty{
hpc: hPc,
pi: pi,
ptyIn: &handleIO{ptyIn},
ptyOut: &handleIO{ptyOut},
cmdIn: &handleIO{cmdIn},
cmdOut: &handleIO{cmdOut},
}
return cpty, nil
}