Skip to content

Commit 6a25bb2

Browse files
authored
Merge pull request #583 from Yumiue/html_progress
feat: Web UI 交互主线 — 文件面板、模型切换、Transcript 安全修复
2 parents 57e5b7e + 740bc45 commit 6a25bb2

71 files changed

Lines changed: 7444 additions & 3009 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

internal/cli/gateway_runtime_bridge.go

Lines changed: 233 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package cli
22

33
import (
4+
"bytes"
45
"context"
56
"encoding/json"
67
"errors"
@@ -13,13 +14,15 @@ import (
1314
"sync"
1415
"sync/atomic"
1516
"time"
17+
"unicode/utf8"
1618

1719
"neo-code/internal/app"
1820
"neo-code/internal/checkpoint"
1921
"neo-code/internal/config"
2022
configstate "neo-code/internal/config/state"
2123
"neo-code/internal/gateway"
2224
providertypes "neo-code/internal/provider/types"
25+
"neo-code/internal/repository"
2326
agentruntime "neo-code/internal/runtime"
2427
agentsession "neo-code/internal/session"
2528
"neo-code/internal/skills"
@@ -31,6 +34,7 @@ const bridgeRuntimeUnavailableErrMsg = "gateway runtime bridge: runtime is unava
3134
const bridgeAskSessionPrefix = "ask"
3235
const bridgeAskRunPrefix = "ask-run"
3336
const bridgeAskRunTTL = 30 * time.Minute
37+
const readFilePreviewLimitBytes int64 = 512 * 1024
3438

3539
type runtimeRunCanceler interface {
3640
CancelRun(runID string) bool
@@ -93,15 +97,11 @@ func defaultBuildGatewayRuntimePort(ctx context.Context, workdir string) (gatewa
9397
_ = bundle.Close()
9498
return nil, nil, err
9599
}
96-
defaultHash := agentsession.HashWorkspaceRoot(defaultWorkspaceRoot)
97-
if _, err := index.Register(defaultWorkspaceRoot, ""); err != nil {
98-
_ = bundle.Close()
99-
return nil, nil, err
100-
}
101-
if err := index.Save(); err != nil {
100+
if err := sanitizeWorkspaceIndex(index, defaultWorkspaceRoot); err != nil {
102101
_ = bundle.Close()
103102
return nil, nil, err
104103
}
104+
defaultHash := agentsession.HashWorkspaceRoot(defaultWorkspaceRoot)
105105

106106
bridge, err := newGatewayRuntimePortBridge(ctx, bundle.Runtime, bundle.SessionStore, bundle.ConfigManager, bundle.ProviderSelection, bundle.ToolRegistry)
107107
if err != nil {
@@ -156,6 +156,40 @@ func defaultBuildGatewayRuntimePort(ctx context.Context, workdir string) (gatewa
156156
return mw, mw.Close, nil
157157
}
158158

159+
// sanitizeWorkspaceIndex 在 Gateway 启动时清洗工作区索引,移除失效或临时残留目录。
160+
func sanitizeWorkspaceIndex(index *agentsession.WorkspaceIndex, defaultWorkspaceRoot string) error {
161+
if index == nil {
162+
return fmt.Errorf("gateway runtime bridge: workspace index is nil")
163+
}
164+
165+
shouldPersistDefault := agentsession.IsPersistentWorkspaceRoot(defaultWorkspaceRoot)
166+
changed := false
167+
if shouldPersistDefault {
168+
if _, err := index.Register(defaultWorkspaceRoot, ""); err != nil {
169+
return err
170+
}
171+
changed = true
172+
}
173+
174+
removed := index.Prune(func(record agentsession.WorkspaceRecord) bool {
175+
path := strings.TrimSpace(record.Path)
176+
if path == "" {
177+
return true
178+
}
179+
if shouldPersistDefault && agentsession.WorkspacePathKey(path) == agentsession.WorkspacePathKey(defaultWorkspaceRoot) {
180+
return false
181+
}
182+
return !agentsession.IsPersistentWorkspaceRoot(path)
183+
})
184+
if len(removed) > 0 {
185+
changed = true
186+
}
187+
if !changed {
188+
return nil
189+
}
190+
return index.Save()
191+
}
192+
159193
// resolveGatewayDefaultWorkspaceRoot 解析网关默认工作区,优先使用显式参数,缺失时回退到配置快照。
160194
func resolveGatewayDefaultWorkspaceRoot(requestedWorkdir string, configWorkdir string) (string, error) {
161195
candidate := strings.TrimSpace(requestedWorkdir)
@@ -194,6 +228,7 @@ type gatewayRuntimePortBridge struct {
194228
sessionStore bridgeSessionStore
195229
configManager configManagerPort
196230
providerSelection providerSelectorPort
231+
repoService *repository.Service
197232
toolRegistry *tools.Registry
198233
events chan gateway.RuntimeEvent
199234

@@ -220,23 +255,30 @@ func newGatewayRuntimePortBridge(
220255
}
221256
var cm configManagerPort
222257
var ps providerSelectorPort
258+
var repoSvc *repository.Service
223259
var tr *tools.Registry
224260
for _, extra := range extras {
225261
switch typed := extra.(type) {
226262
case configManagerPort:
227263
cm = typed
228264
case providerSelectorPort:
229265
ps = typed
266+
case *repository.Service:
267+
repoSvc = typed
230268
case *tools.Registry:
231269
tr = typed
232270
}
233271
}
272+
if repoSvc == nil {
273+
repoSvc = repository.NewService()
274+
}
234275

235276
bridge := &gatewayRuntimePortBridge{
236277
runtime: runtimeSvc,
237278
sessionStore: store,
238279
configManager: cm,
239280
providerSelection: ps,
281+
repoService: repoSvc,
240282
toolRegistry: tr,
241283
events: make(chan gateway.RuntimeEvent, 128),
242284
stopCh: make(chan struct{}),
@@ -672,6 +714,142 @@ func (b *gatewayRuntimePortBridge) ListFiles(ctx context.Context, input gateway.
672714
return result, nil
673715
}
674716

717+
// ReadFile 读取工作目录内文件的只读预览内容。
718+
func (b *gatewayRuntimePortBridge) ReadFile(ctx context.Context, input gateway.ReadFileInput) (gateway.ReadFileResult, error) {
719+
if err := b.ensureRuntimeAccess(input.SubjectID); err != nil {
720+
return gateway.ReadFileResult{}, err
721+
}
722+
root, err := b.resolveListFilesRoot(ctx, gateway.ListFilesInput{
723+
SubjectID: input.SubjectID,
724+
SessionID: input.SessionID,
725+
Workdir: input.Workdir,
726+
})
727+
if err != nil {
728+
return gateway.ReadFileResult{}, err
729+
}
730+
target, relativePath, err := resolveSafeListFilesPath(root, input.Path)
731+
if err != nil {
732+
return gateway.ReadFileResult{}, err
733+
}
734+
info, err := os.Stat(target)
735+
if err != nil {
736+
return gateway.ReadFileResult{}, err
737+
}
738+
if info.IsDir() {
739+
return gateway.ReadFileResult{}, fmt.Errorf("gateway runtime bridge: readFile path is a directory")
740+
}
741+
742+
result := gateway.ReadFileResult{
743+
Path: filepath.ToSlash(relativePath),
744+
Encoding: "utf-8",
745+
Size: info.Size(),
746+
ModTime: info.ModTime().UTC().Format(time.RFC3339),
747+
}
748+
if info.Size() > readFilePreviewLimitBytes {
749+
result.Truncated = true
750+
return result, nil
751+
}
752+
753+
data, err := os.ReadFile(target)
754+
if err != nil {
755+
return gateway.ReadFileResult{}, err
756+
}
757+
if isBinaryPreviewContent(data) {
758+
result.Encoding = "binary"
759+
result.IsBinary = true
760+
return result, nil
761+
}
762+
result.Content = string(data)
763+
return result, nil
764+
}
765+
766+
// ListGitDiffFiles 返回当前工作树相对 HEAD 的 Git 变更文件列表。
767+
func (b *gatewayRuntimePortBridge) ListGitDiffFiles(ctx context.Context, input gateway.ListGitDiffFilesInput) (gateway.ListGitDiffFilesResult, error) {
768+
if err := b.ensureRuntimeAccess(input.SubjectID); err != nil {
769+
return gateway.ListGitDiffFilesResult{}, err
770+
}
771+
root, err := b.resolveListFilesRoot(ctx, gateway.ListFilesInput{
772+
SubjectID: input.SubjectID,
773+
SessionID: input.SessionID,
774+
Workdir: input.Workdir,
775+
})
776+
if err != nil {
777+
return gateway.ListGitDiffFilesResult{}, err
778+
}
779+
if b.repoService == nil {
780+
return gateway.ListGitDiffFilesResult{}, fmt.Errorf("gateway runtime bridge: repository service is unavailable")
781+
}
782+
inspection, err := b.repoService.Inspect(ctx, root, repository.InspectOptions{ChangedFilesLimit: 200})
783+
if err != nil {
784+
return gateway.ListGitDiffFilesResult{}, err
785+
}
786+
result := gateway.ListGitDiffFilesResult{
787+
InGitRepo: inspection.Summary.InGitRepo,
788+
Branch: inspection.Summary.Branch,
789+
Ahead: inspection.Summary.Ahead,
790+
Behind: inspection.Summary.Behind,
791+
Files: make([]gateway.GitDiffEntry, 0, len(inspection.ChangedFiles.Files)),
792+
}
793+
if !inspection.Summary.InGitRepo {
794+
return result, nil
795+
}
796+
result.Truncated = inspection.ChangedFiles.Truncated
797+
result.TotalCount = inspection.ChangedFiles.TotalCount
798+
for _, file := range inspection.ChangedFiles.Files {
799+
result.Files = append(result.Files, gateway.GitDiffEntry{
800+
Path: filepath.ToSlash(file.Path),
801+
OldPath: filepath.ToSlash(file.OldPath),
802+
Status: string(file.Status),
803+
})
804+
}
805+
return result, nil
806+
}
807+
808+
// ReadGitDiffFile 读取单个 Git 变更文件的双文本预览内容。
809+
func (b *gatewayRuntimePortBridge) ReadGitDiffFile(ctx context.Context, input gateway.ReadGitDiffFileInput) (gateway.ReadGitDiffFileResult, error) {
810+
if err := b.ensureRuntimeAccess(input.SubjectID); err != nil {
811+
return gateway.ReadGitDiffFileResult{}, err
812+
}
813+
root, err := b.resolveListFilesRoot(ctx, gateway.ListFilesInput{
814+
SubjectID: input.SubjectID,
815+
SessionID: input.SessionID,
816+
Workdir: input.Workdir,
817+
})
818+
if err != nil {
819+
return gateway.ReadGitDiffFileResult{}, err
820+
}
821+
if b.repoService == nil {
822+
return gateway.ReadGitDiffFileResult{}, fmt.Errorf("gateway runtime bridge: repository service is unavailable")
823+
}
824+
result, err := b.repoService.ReadGitDiffFile(ctx, root, input.Path, readFilePreviewLimitBytes)
825+
if err != nil {
826+
return gateway.ReadGitDiffFileResult{}, err
827+
}
828+
return gateway.ReadGitDiffFileResult{
829+
Path: filepath.ToSlash(result.Path),
830+
OldPath: filepath.ToSlash(result.OldPath),
831+
Status: string(result.Status),
832+
OriginalContent: result.OriginalContent,
833+
ModifiedContent: result.ModifiedContent,
834+
Encoding: result.Encoding,
835+
IsBinary: result.IsBinary,
836+
Truncated: result.Truncated,
837+
OriginalSize: result.OriginalSize,
838+
ModifiedSize: result.ModifiedSize,
839+
}, nil
840+
}
841+
842+
// isBinaryPreviewContent 用启发式规则判断预览内容是否应视为二进制。
843+
func isBinaryPreviewContent(data []byte) bool {
844+
if len(data) == 0 {
845+
return false
846+
}
847+
if bytes.IndexByte(data, 0) >= 0 {
848+
return true
849+
}
850+
return !utf8.Valid(data)
851+
}
852+
675853
// ListModels 列出可用模型;有会话时按会话有效 provider 返回,无会话时按全局默认 provider 返回。
676854
func (b *gatewayRuntimePortBridge) ListModels(ctx context.Context, input gateway.ListModelsInput) ([]gateway.ModelEntry, error) {
677855
if err := b.ensureRuntimeAccess(input.SubjectID); err != nil {
@@ -862,6 +1040,9 @@ func (b *gatewayRuntimePortBridge) SelectProviderModel(ctx context.Context, inpu
8621040
return gateway.ProviderSelectionResult{}, err
8631041
}
8641042
}
1043+
if err := b.SyncSessionsProviderModel(ctx, selection.ProviderID, selection.ModelID); err != nil {
1044+
return gateway.ProviderSelectionResult{}, err
1045+
}
8651046
return gateway.ProviderSelectionResult{ProviderID: selection.ProviderID, ModelID: selection.ModelID}, nil
8661047
}
8671048

@@ -1772,6 +1953,52 @@ func (b *gatewayRuntimePortBridge) loadStoredSession(ctx context.Context, sessio
17721953
return loader.LoadSession(ctx, strings.TrimSpace(sessionID))
17731954
}
17741955

1956+
// SyncSessionsProviderModel 将当前工作区已列出的会话统一切换到新的 provider/model,避免全局切换后会话元数据继续滞留旧值。
1957+
func (b *gatewayRuntimePortBridge) SyncSessionsProviderModel(
1958+
ctx context.Context,
1959+
providerID string,
1960+
modelID string,
1961+
) error {
1962+
if b == nil || b.sessionStore == nil || b.runtime == nil {
1963+
return nil
1964+
}
1965+
providerID = strings.TrimSpace(providerID)
1966+
modelID = strings.TrimSpace(modelID)
1967+
if providerID == "" || modelID == "" {
1968+
return nil
1969+
}
1970+
1971+
summaries, err := b.runtime.ListSessions(ctx)
1972+
if err != nil {
1973+
return err
1974+
}
1975+
for _, summary := range summaries {
1976+
sessionID := strings.TrimSpace(summary.ID)
1977+
if sessionID == "" {
1978+
continue
1979+
}
1980+
session, loadErr := b.loadStoredSession(ctx, sessionID)
1981+
if loadErr != nil {
1982+
if errors.Is(loadErr, agentsession.ErrSessionNotFound) {
1983+
continue
1984+
}
1985+
return loadErr
1986+
}
1987+
head := session.HeadSnapshot()
1988+
head.Provider = providerID
1989+
head.Model = modelID
1990+
if updateErr := b.sessionStore.UpdateSessionState(ctx, agentsession.UpdateSessionStateInput{
1991+
SessionID: session.ID,
1992+
Title: session.Title,
1993+
UpdatedAt: time.Now().UTC(),
1994+
Head: head,
1995+
}); updateErr != nil {
1996+
return updateErr
1997+
}
1998+
}
1999+
return nil
2000+
}
2001+
17752002
// resolveSafeListFilesPath 将前端传入的相对路径限制在根目录内。
17762003
func resolveSafeListFilesPath(root string, rawPath string) (string, string, error) {
17772004
rootAbs, err := filepath.Abs(filepath.Clean(root))

0 commit comments

Comments
 (0)