mirror of
https://github.com/RuoJi6/ARTEX.git
synced 2026-10-31 18:23:50 +08:00
Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
54db158262 |
No files matched your search
@@ -12,7 +12,6 @@ import (
|
||||
// Missing host/setting means off. Rechecked on every write for running sessions.
|
||||
var IntelligenceEnabled func() bool
|
||||
var IntelligenceRecord func(context.Context, int64, int64, string, db.IntelligenceInput) (int64, error)
|
||||
var CredentialRecorded func()
|
||||
|
||||
func intelligenceEnabled() bool { return IntelligenceEnabled != nil && IntelligenceEnabled() }
|
||||
func intelligenceSchema() map[string]any {
|
||||
|
||||
+1
-15
@@ -30,10 +30,7 @@ import (
|
||||
|
||||
// Config describes the LLM backend resolved from the environment.
|
||||
type Config struct {
|
||||
Format llm.Format
|
||||
// Jev marks the TypeSafe System One judge protocol. It must never be sent
|
||||
// through the chat/completions provider path.
|
||||
Jev bool
|
||||
Format llm.Format
|
||||
BaseURL string
|
||||
APIKey string
|
||||
Model string
|
||||
@@ -207,11 +204,6 @@ func ConfigFrom(provider, model, baseURL, apiKey, proxy string) Config {
|
||||
Stream: true, // 默认流式;调用方按 profile 覆盖
|
||||
}
|
||||
switch strings.TrimSpace(provider) {
|
||||
case "jev":
|
||||
c.Jev = true
|
||||
if c.Model == "" {
|
||||
c.Model = "jev-latest"
|
||||
}
|
||||
case "openai":
|
||||
c.Format = llm.FormatOpenAI
|
||||
// provider appends "/chat/completions"; tolerate a full endpoint URL.
|
||||
@@ -249,9 +241,6 @@ func isFalsy(s string) bool {
|
||||
|
||||
// Provider returns the short provider name ("anthropic"/"openai").
|
||||
func (c Config) Provider() string {
|
||||
if c.Jev {
|
||||
return "jev"
|
||||
}
|
||||
switch c.Format {
|
||||
case llm.FormatOpenAI:
|
||||
return "openai"
|
||||
@@ -270,9 +259,6 @@ func (c Config) NewProvider() (llm.Provider, error) {
|
||||
// NewProviderWithLimiter reuses quota history across provider rebuilds. Callers
|
||||
// sharing a profile must retain the same limiter outside their provider cache.
|
||||
func (c Config) NewProviderWithLimiter(limiter *RequestLimiter) (llm.Provider, error) {
|
||||
if c.Jev {
|
||||
return nil, fmt.Errorf("Jev System One 仅可用于拦截判定模型,不能作为聊天 provider")
|
||||
}
|
||||
client, err := quotaAwareHTTPClient(c.Proxy, c.SessionHeaderKey)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -1,13 +0,0 @@
|
||||
package agent
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestJevConfigCannotBuildChatProvider(t *testing.T) {
|
||||
c := ConfigFrom("jev", "", "", "key", "")
|
||||
if !c.Jev || c.Provider() != "jev" {
|
||||
t.Fatalf("unexpected Jev config: %+v", c)
|
||||
}
|
||||
if _, err := c.NewProvider(); err == nil {
|
||||
t.Fatal("Jev config must be rejected by chat provider construction")
|
||||
}
|
||||
}
|
||||
+2
-28
@@ -139,7 +139,7 @@ func (d *DB) ProfileByID(id int64) (*LLMProfile, error) {
|
||||
// walk the slice front to back.
|
||||
func (d *DB) PoolProfiles() ([]*LLMProfile, error) {
|
||||
rows, err := d.Query(`SELECT ` + profileColsKey + ` FROM llm_profiles
|
||||
WHERE format <> 'jev' AND COALESCE(api_key,'') <> '' AND (is_default OR NOT pool_exclude)
|
||||
WHERE COALESCE(api_key,'') <> '' AND (is_default OR NOT pool_exclude)
|
||||
ORDER BY is_default DESC, priority DESC, id ASC`)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -158,12 +158,6 @@ ORDER BY is_default DESC, priority DESC, id ASC`)
|
||||
|
||||
// SaveProfile inserts (id==0) or updates a profile. Empty apiKey on update keeps existing.
|
||||
func (d *DB) SaveProfile(p *LLMProfile) (int64, error) {
|
||||
if p.Format == "jev" && p.ID != 0 {
|
||||
var active bool
|
||||
if err := d.QueryRow(`SELECT is_default FROM llm_profiles WHERE id=$1`, p.ID).Scan(&active); err == nil && active {
|
||||
return 0, ErrJevProfileJudgeOnly
|
||||
}
|
||||
}
|
||||
hint := p.APIKeyHint
|
||||
if len(p.APIKey) >= 4 {
|
||||
hint = "…" + p.APIKey[len(p.APIKey)-4:]
|
||||
@@ -193,7 +187,6 @@ var (
|
||||
ErrActiveLLMProfileDelete = errors.New("cannot delete the active LLM profile; activate another profile first")
|
||||
ErrLLMProfileReferencesChanged = errors.New("LLM profile references changed while deleting; retry the request")
|
||||
ErrLLMProfileNotFound = errors.New("LLM profile not found")
|
||||
ErrJevProfileJudgeOnly = errors.New("Jev System One 配置仅可用于拦截判定模型,不能用于对话或任务")
|
||||
)
|
||||
|
||||
func (d *DB) DeleteProfile(id int64) error {
|
||||
@@ -429,16 +422,6 @@ func (d *DB) SetActiveProfile(id int64) error {
|
||||
return err
|
||||
}
|
||||
defer tx.Rollback()
|
||||
var format string
|
||||
if err := tx.QueryRow(`SELECT format FROM llm_profiles WHERE id=$1 FOR KEY SHARE`, id).Scan(&format); err != nil {
|
||||
if err == sql.ErrNoRows {
|
||||
return ErrLLMProfileNotFound
|
||||
}
|
||||
return err
|
||||
}
|
||||
if format == "jev" {
|
||||
return ErrJevProfileJudgeOnly
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE llm_profiles SET is_default=false WHERE is_default`); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -630,15 +613,6 @@ func (d *DB) SetAgentLLMProfile(key string, id *int64) error {
|
||||
if err := lockLLMProfileForReference(tx, id); err != nil {
|
||||
return err
|
||||
}
|
||||
if id != nil {
|
||||
var format string
|
||||
if err := tx.QueryRow(`SELECT format FROM llm_profiles WHERE id=$1`, *id).Scan(&format); err != nil {
|
||||
return err
|
||||
}
|
||||
if format == "jev" {
|
||||
return ErrJevProfileJudgeOnly
|
||||
}
|
||||
}
|
||||
if _, err := tx.Exec(`UPDATE agents SET llm_profile_id=$1 WHERE id=$2`, id, agentID); err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -841,7 +815,7 @@ type MCPServer struct {
|
||||
Env json.RawMessage `json:"env"`
|
||||
URL string `json:"url,omitempty"`
|
||||
Enabled bool `json:"enabled"`
|
||||
Insecure bool `json:"insecure"` // http: skip TLS cert verification (self-signed servers, issue #108)
|
||||
Insecure bool `json:"insecure"` // http: skip TLS cert verification (self-signed servers, issue #108)
|
||||
Tools []string `json:"tools,omitempty"` // cached tool names (mcp_tools_cache)
|
||||
}
|
||||
|
||||
|
||||
@@ -183,25 +183,6 @@ func TestConfigStores(t *testing.T) {
|
||||
d.DeleteMCP(mid)
|
||||
}
|
||||
|
||||
func TestJevProfileCannotBeActivatedOrAgentBound(t *testing.T) {
|
||||
d, err := Open(testDSN(t))
|
||||
if err != nil {
|
||||
t.Skipf("postgres unavailable (%v) — skipping", err)
|
||||
}
|
||||
defer d.Close()
|
||||
id, err := d.SaveProfile(&LLMProfile{Name: "t-jev-only", Format: "jev", Model: "jev-latest", APIKey: "jev-key"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() { _, _ = d.Exec(`DELETE FROM llm_profiles WHERE id=$1`, id) })
|
||||
if err := d.SetActiveProfile(id); !errors.Is(err, ErrJevProfileJudgeOnly) {
|
||||
t.Fatalf("activate Jev error=%v, want %v", err, ErrJevProfileJudgeOnly)
|
||||
}
|
||||
if err := d.SetAgentLLMProfile("planner", &id); !errors.Is(err, ErrJevProfileJudgeOnly) {
|
||||
t.Fatalf("bind Jev error=%v, want %v", err, ErrJevProfileJudgeOnly)
|
||||
}
|
||||
}
|
||||
|
||||
func contains(s, sub string) bool {
|
||||
return len(s) >= len(sub) && (indexOf(s, sub) >= 0)
|
||||
}
|
||||
|
||||
+16
-17
@@ -39,23 +39,22 @@ type CredentialTraffic struct {
|
||||
Accounts []int `json:"accounts"`
|
||||
}
|
||||
type CredentialAnalysis struct {
|
||||
ConversationID int64 `json:"conversation_id,omitempty"`
|
||||
ForceExtract bool `json:"force_extract,omitempty"`
|
||||
TrafficNote string `json:"traffic_note,omitempty"`
|
||||
State string `json:"state"`
|
||||
Lease string `json:"lease,omitempty"`
|
||||
LeaseUntil time.Time `json:"lease_until,omitempty"`
|
||||
SourceHash string `json:"source_hash,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Accounts []CredentialAccount `json:"accounts"`
|
||||
Traffic []CredentialTraffic `json:"traffic"`
|
||||
TrafficState string `json:"traffic_state"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
ProcessedAt *time.Time `json:"processed_at,omitempty"`
|
||||
ForceExtract bool `json:"force_extract,omitempty"`
|
||||
TrafficNote string `json:"traffic_note,omitempty"`
|
||||
State string `json:"state"`
|
||||
Lease string `json:"lease,omitempty"`
|
||||
LeaseUntil time.Time `json:"lease_until,omitempty"`
|
||||
SourceHash string `json:"source_hash,omitempty"`
|
||||
Method string `json:"method,omitempty"`
|
||||
Accounts []CredentialAccount `json:"accounts"`
|
||||
Traffic []CredentialTraffic `json:"traffic"`
|
||||
TrafficState string `json:"traffic_state"`
|
||||
Note string `json:"note,omitempty"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Model string `json:"model,omitempty"`
|
||||
InputTokens int `json:"input_tokens"`
|
||||
OutputTokens int `json:"output_tokens"`
|
||||
ProcessedAt *time.Time `json:"processed_at,omitempty"`
|
||||
}
|
||||
|
||||
func CredentialSourceHash(content string) string {
|
||||
|
||||
+18
-62
@@ -23,21 +23,20 @@ type DBFinding struct {
|
||||
ReportEvidenceVersion int64
|
||||
TrafficBindings []FindingTrafficBinding // populated only for export
|
||||
|
||||
ID int64
|
||||
TaskID *int64
|
||||
NodeID *int64
|
||||
VulnClass string
|
||||
Name string // 漏洞名称(可读标题);为空时前端回退展示 VulnClass
|
||||
Severity string
|
||||
Summary string
|
||||
Evidence string
|
||||
Worker string
|
||||
AssetIDs []int64
|
||||
Status string
|
||||
Report string // 详细报告(Markdown);仅 GetFinding 填充,列表查询不带
|
||||
ReportConversationID *int64 `json:"report_conversation_id,omitempty"`
|
||||
CreatedAt time.Time
|
||||
TaskDescription string // populated via LEFT JOIN on tasks
|
||||
ID int64
|
||||
TaskID *int64
|
||||
NodeID *int64
|
||||
VulnClass string
|
||||
Name string // 漏洞名称(可读标题);为空时前端回退展示 VulnClass
|
||||
Severity string
|
||||
Summary string
|
||||
Evidence string
|
||||
Worker string
|
||||
AssetIDs []int64
|
||||
Status string
|
||||
Report string // 详细报告(Markdown);仅 GetFinding 填充,列表查询不带
|
||||
CreatedAt time.Time
|
||||
TaskDescription string // populated via LEFT JOIN on tasks
|
||||
}
|
||||
|
||||
// Finding triage states (findings.status).
|
||||
@@ -107,9 +106,9 @@ func (d *DB) AddFinding(taskID, nodeID int64, vulnclass, name, severity, summary
|
||||
// findingSelectCols is the column list (with task_description join) every finding
|
||||
// list query selects, so scanFinding stays in sync across callers.
|
||||
const findingSelectCols = `f.id, f.task_id, f.node_id, f.vulnclass, COALESCE(f.name, ''), f.severity, f.summary,
|
||||
f.evidence, f.worker, f.asset_ids, COALESCE(f.status, 'pending'), f.created_at,
|
||||
f.evidence, f.worker, f.asset_ids, COALESCE(f.status, 'pending'), f.created_at,
|
||||
COALESCE(t.description, '') AS task_description, f.evidence_version, f.report_evidence_version,
|
||||
(SELECT count(*) FROM finding_traffic_bindings b WHERE b.finding_id=f.id)`
|
||||
(SELECT count(*) FROM finding_traffic_bindings b WHERE b.finding_id=f.id)`
|
||||
|
||||
// scanFindings materializes rows selected via findingSelectCols.
|
||||
func scanFindings(rows interface {
|
||||
@@ -662,14 +661,13 @@ func (d *DB) FindingStats() (*FindingStats, error) {
|
||||
func (d *DB) GetFinding(id int64) (*DBFinding, error) {
|
||||
f := &DBFinding{}
|
||||
var aidsJSON string
|
||||
var reportConv sql.NullInt64
|
||||
err := d.QueryRow(`SELECT `+findingSelectCols+`, COALESCE(f.report, ''), f.report_conversation_id
|
||||
err := d.QueryRow(`SELECT `+findingSelectCols+`, COALESCE(f.report, '')
|
||||
FROM findings f
|
||||
LEFT JOIN tasks t ON f.task_id = t.id
|
||||
WHERE f.id = $1`, id).Scan(
|
||||
&f.ID, &f.TaskID, &f.NodeID, &f.VulnClass, &f.Name, &f.Severity,
|
||||
&f.Summary, &f.Evidence, &f.Worker, &aidsJSON, &f.Status, &f.CreatedAt,
|
||||
&f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.TrafficCount, &f.Report, &reportConv)
|
||||
&f.TaskDescription, &f.EvidenceVersion, &f.ReportEvidenceVersion, &f.TrafficCount, &f.Report)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
@@ -677,51 +675,9 @@ func (d *DB) GetFinding(id int64) (*DBFinding, error) {
|
||||
return nil, err
|
||||
}
|
||||
_ = json.Unmarshal([]byte(aidsJSON), &f.AssetIDs)
|
||||
if reportConv.Valid {
|
||||
f.ReportConversationID = &reportConv.Int64
|
||||
}
|
||||
if f.ReportConversationID == nil && strings.TrimSpace(f.Report) != "" {
|
||||
if resolved, resolveErr := d.ResolveFindingReportConversation(f.ID); resolveErr == nil {
|
||||
f.ReportConversationID = resolved
|
||||
}
|
||||
}
|
||||
return f, nil
|
||||
}
|
||||
|
||||
func (d *DB) SetFindingReportConversationID(findingID, conversationID int64) error {
|
||||
_, err := d.Exec(`UPDATE findings SET report_conversation_id=$2 WHERE id=$1`, findingID, conversationID)
|
||||
return err
|
||||
}
|
||||
|
||||
// ResolveFindingReportConversation backfills links for reports generated before
|
||||
// report_conversation_id was persisted.
|
||||
func (d *DB) ResolveFindingReportConversation(findingID int64) (*int64, error) {
|
||||
var id int64
|
||||
err := d.QueryRow(`SELECT c.id FROM conversations c
|
||||
JOIN conversation_activities a ON a.conversation_id=c.id
|
||||
WHERE c.agent_key='reporter' AND (
|
||||
c.title = '漏洞报告 · finding#' || $1::text OR
|
||||
c.title ILIKE '%finding#' || $1::text || '%' OR
|
||||
a.detail ILIKE '%finding_id=' || $1::text || '%' OR
|
||||
a.detail ILIKE '%"finding_id":' || $1::text || '%'
|
||||
)
|
||||
ORDER BY CASE WHEN EXISTS (
|
||||
SELECT 1 FROM conversation_activities r
|
||||
WHERE r.conversation_id=c.id AND (r.tool='update_finding_report' OR r.summary ILIKE 'finding % report updated%')
|
||||
) THEN 0 ELSE 1 END,
|
||||
c.created_at ASC, c.id ASC LIMIT 1`, findingID).Scan(&id)
|
||||
if err == sql.ErrNoRows {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := d.SetFindingReportConversationID(findingID, id); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &id, nil
|
||||
}
|
||||
|
||||
// DeleteFinding removes a finding entirely: the standalone findings row and its
|
||||
// originating exploration node (kind='finding'), so it disappears from the findings
|
||||
// list, the per-task 发现 Tab, and the exploration graph alike. Deleting the node
|
||||
|
||||
+3
-5
@@ -362,7 +362,7 @@ CREATE TABLE IF NOT EXISTS settings (
|
||||
CREATE TABLE IF NOT EXISTS llm_profiles (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
format TEXT NOT NULL CHECK (format IN ('openai','anthropic','openai-responses','jev')),
|
||||
format TEXT NOT NULL CHECK (format IN ('openai','anthropic','openai-responses')),
|
||||
base_url TEXT,
|
||||
proxy TEXT,
|
||||
model TEXT NOT NULL,
|
||||
@@ -415,11 +415,11 @@ ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS priority INTEGER NOT NULL
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS pool_exclude BOOLEAN NOT NULL DEFAULT false;
|
||||
-- 流式开关;补旧库。默认 true = 保持既有的流式行为,旧配置无感升级。
|
||||
ALTER TABLE llm_profiles ADD COLUMN IF NOT EXISTS streaming BOOLEAN NOT NULL DEFAULT true;
|
||||
-- 放开 format 约束以容纳 openai-responses(OpenAI Responses API)和 Jev System One;补旧库。
|
||||
-- 放开 format 约束以容纳 openai-responses(OpenAI Responses API);补旧库。
|
||||
-- 每次启动执行,幂等:先删旧 CHECK 再建含三值的新 CHECK。
|
||||
ALTER TABLE llm_profiles DROP CONSTRAINT IF EXISTS llm_profiles_format_check;
|
||||
ALTER TABLE llm_profiles ADD CONSTRAINT llm_profiles_format_check
|
||||
CHECK (format IN ('openai','anthropic','openai-responses','jev'));
|
||||
CHECK (format IN ('openai','anthropic','openai-responses'));
|
||||
|
||||
-- 输出上限及其字段名;补旧库。默认 0 / '' = 不发送上限、沿用 max_tokens 字段名,
|
||||
-- 旧配置行为完全不变。
|
||||
@@ -1162,13 +1162,11 @@ CREATE TABLE IF NOT EXISTS findings (
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
-- 漏洞详细报告(Markdown);默认空,仅详情页读取/展示,不进列表接口以免 payload 膨胀。
|
||||
report TEXT NOT NULL DEFAULT '',
|
||||
report_conversation_id BIGINT REFERENCES conversations(id) ON DELETE SET NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||
);
|
||||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'pending';
|
||||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS name TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS report TEXT NOT NULL DEFAULT '';
|
||||
ALTER TABLE findings ADD COLUMN IF NOT EXISTS report_conversation_id BIGINT REFERENCES conversations(id) ON DELETE SET NULL;
|
||||
CREATE INDEX IF NOT EXISTS idx_findings_task ON findings(task_id, created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_findings_time ON findings(created_at DESC);
|
||||
CREATE INDEX IF NOT EXISTS idx_findings_status ON findings(status, created_at DESC);
|
||||
|
||||
+10
-35
@@ -311,7 +311,6 @@ const (
|
||||
settingJudgeAskTimeoutSecs = "llm_judge_ask_timeout_seconds"
|
||||
settingJudgeAskTimeoutAction = "llm_judge_ask_timeout_action"
|
||||
settingJudgeIncompleteScript = "llm_judge_incomplete_script_action"
|
||||
settingJudgeConfidence = "llm_judge_confidence_threshold"
|
||||
)
|
||||
|
||||
// Judge default values.
|
||||
@@ -321,7 +320,6 @@ const (
|
||||
defaultJudgeFailAction = "ask"
|
||||
defaultJudgeAskTimeoutSecs = 300
|
||||
defaultJudgeAskTimeoutAction = "deny"
|
||||
defaultJudgeConfidence = 0.55
|
||||
// DefaultIncompleteScriptAction keeps incomplete snapshots with the judge.
|
||||
DefaultIncompleteScriptAction = "model"
|
||||
)
|
||||
@@ -329,17 +327,16 @@ const (
|
||||
// JudgeConfig is the resolved LLM-fallback-judge configuration. Prompt is always
|
||||
// non-empty (falls back to DefaultJudgePrompt).
|
||||
type JudgeConfig struct {
|
||||
Enabled bool `json:"enabled"`
|
||||
ProfileID int64 `json:"profile_id"` // 0 = follow active/default
|
||||
Prompt string `json:"prompt"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"` // per attempt
|
||||
RetryCount int `json:"retry_count"` // additional attempts; 0 = disabled
|
||||
RetryIntervalMS int `json:"retry_interval_ms"`
|
||||
FailAction string `json:"fail_action"` // allow|ask|deny
|
||||
AskTimeoutSeconds int `json:"ask_timeout_seconds"`
|
||||
AskTimeoutAction string `json:"ask_timeout_action"` // allow|deny
|
||||
IncompleteScriptAction string `json:"incomplete_script_action"` // ask|model
|
||||
ConfidenceThreshold float64 `json:"confidence_threshold"` // Jev only; below this becomes ask
|
||||
Enabled bool `json:"enabled"`
|
||||
ProfileID int64 `json:"profile_id"` // 0 = follow active/default
|
||||
Prompt string `json:"prompt"`
|
||||
TimeoutSeconds int `json:"timeout_seconds"` // per attempt
|
||||
RetryCount int `json:"retry_count"` // additional attempts; 0 = disabled
|
||||
RetryIntervalMS int `json:"retry_interval_ms"`
|
||||
FailAction string `json:"fail_action"` // allow|ask|deny
|
||||
AskTimeoutSeconds int `json:"ask_timeout_seconds"`
|
||||
AskTimeoutAction string `json:"ask_timeout_action"` // allow|deny
|
||||
IncompleteScriptAction string `json:"incomplete_script_action"` // ask|model
|
||||
}
|
||||
|
||||
// judgeConfig reads the judge configuration from settings, applying defaults for
|
||||
@@ -356,7 +353,6 @@ func (i *Interceptor) judgeConfig() JudgeConfig {
|
||||
AskTimeoutSeconds: i.getSettingInt(settingJudgeAskTimeoutSecs, defaultJudgeAskTimeoutSecs),
|
||||
AskTimeoutAction: i.getSettingChoice(settingJudgeAskTimeoutAction, defaultJudgeAskTimeoutAction, "allow", "deny"),
|
||||
IncompleteScriptAction: i.getSettingChoice(settingJudgeIncompleteScript, DefaultIncompleteScriptAction, "ask", "model"),
|
||||
ConfidenceThreshold: i.getSettingFloat(settingJudgeConfidence, defaultJudgeConfidence),
|
||||
}
|
||||
// Prompt: stored value if non-empty, else the built-in template.
|
||||
if v, ok, _ := i.db.GetSetting(settingJudgePrompt); ok && strings.TrimSpace(v) != "" {
|
||||
@@ -370,24 +366,9 @@ func (i *Interceptor) judgeConfig() JudgeConfig {
|
||||
if c.AskTimeoutSeconds <= 0 {
|
||||
c.AskTimeoutSeconds = defaultJudgeAskTimeoutSecs
|
||||
}
|
||||
if c.ConfidenceThreshold <= 0 || c.ConfidenceThreshold > 1 {
|
||||
c.ConfidenceThreshold = defaultJudgeConfidence
|
||||
}
|
||||
return c
|
||||
}
|
||||
|
||||
func (i *Interceptor) getSettingFloat(key string, def float64) float64 {
|
||||
v, ok, err := i.db.GetSetting(key)
|
||||
if err != nil || !ok {
|
||||
return def
|
||||
}
|
||||
n, err := strconv.ParseFloat(strings.TrimSpace(v), 64)
|
||||
if err != nil {
|
||||
return def
|
||||
}
|
||||
return n
|
||||
}
|
||||
|
||||
func (i *Interceptor) getSettingInt(key string, def int) int {
|
||||
v, ok, err := i.db.GetSetting(key)
|
||||
if err != nil || !ok {
|
||||
@@ -460,12 +441,6 @@ func (i *Interceptor) SetJudgeConfig(c JudgeConfig) error {
|
||||
if err := i.db.SetSetting(settingJudgeIncompleteScript, c.IncompleteScriptAction); err != nil {
|
||||
return err
|
||||
}
|
||||
if c.ConfidenceThreshold <= 0 || c.ConfidenceThreshold > 1 {
|
||||
c.ConfidenceThreshold = defaultJudgeConfidence
|
||||
}
|
||||
if err := i.db.SetSetting(settingJudgeConfidence, strconv.FormatFloat(c.ConfidenceThreshold, 'f', 4, 64)); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,224 +0,0 @@
|
||||
package jev
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
const (
|
||||
DefaultBaseURL = "https://api.typesafe.ai"
|
||||
DefaultModel = "jev-latest"
|
||||
maxStateBytes = 24000
|
||||
)
|
||||
|
||||
// Config contains only the System One connection settings. Jev is deliberately
|
||||
// not represented as an agent/llm.Provider format.
|
||||
type Config struct {
|
||||
BaseURL string
|
||||
APIKey string
|
||||
Model string
|
||||
Client *http.Client
|
||||
}
|
||||
|
||||
type Request struct {
|
||||
Model string `json:"model"`
|
||||
State any `json:"state"`
|
||||
Questions map[string]ChoiceQuestion `json:"questions"`
|
||||
}
|
||||
|
||||
type ChoiceQuestion struct {
|
||||
Type string `json:"type"`
|
||||
Instructions string `json:"instructions"`
|
||||
Criteria map[string]string `json:"criteria"`
|
||||
}
|
||||
|
||||
type Response struct {
|
||||
Answers map[string]Answer `json:"answers"`
|
||||
Usage map[string]any `json:"usage,omitempty"`
|
||||
}
|
||||
|
||||
type Answer struct {
|
||||
Type string `json:"type"`
|
||||
Choice string `json:"choice"`
|
||||
Confidence float64 `json:"confidence"`
|
||||
Probabilities map[string]float64 `json:"probabilities"`
|
||||
}
|
||||
|
||||
func (c Config) endpoint() (string, error) {
|
||||
base := strings.TrimSpace(c.BaseURL)
|
||||
if base == "" {
|
||||
base = DefaultBaseURL
|
||||
}
|
||||
base = strings.TrimRight(base, "/")
|
||||
if strings.HasSuffix(base, "/v1/systemone") {
|
||||
return base, nil
|
||||
}
|
||||
if strings.HasSuffix(base, "/v1") {
|
||||
return base + "/systemone", nil
|
||||
}
|
||||
u, err := url.Parse(base)
|
||||
if err != nil || u.Scheme == "" || u.Host == "" {
|
||||
return "", fmt.Errorf("System One Base URL 无效")
|
||||
}
|
||||
return base + "/v1/systemone", nil
|
||||
}
|
||||
|
||||
func BuildRequest(input intercept.ReviewInput, prompt, model string) (Request, error) {
|
||||
if strings.TrimSpace(model) == "" {
|
||||
model = DefaultModel
|
||||
}
|
||||
policy := strings.TrimSpace(prompt)
|
||||
if policy == "" {
|
||||
policy = "根据工具调用、参数、工作目录和脚本快照,选择 allow、ask 或 deny。"
|
||||
}
|
||||
if len(policy) > 6000 {
|
||||
policy = policy[:6000] + "…"
|
||||
}
|
||||
// System One accepts structured state. Keep policy and review evidence in one
|
||||
// bounded object; the model receives no requirement to emit free-form JSON.
|
||||
policy = "判定政策(仅作为选择依据;直接回答 choice,不要输出 JSON 或自由文本解释):\n" + policy
|
||||
state := map[string]any{"review_input": input, "policy": policy}
|
||||
b, err := json.Marshal(state)
|
||||
if err != nil {
|
||||
return Request{}, err
|
||||
}
|
||||
if len(b) > maxStateBytes {
|
||||
// Arguments can be supplied by an untrusted tool. Preserve valid JSON while
|
||||
// bounding the request by replacing the evidence with a clipped JSON string.
|
||||
trimmed := string(b)
|
||||
if len(trimmed) > 16000 {
|
||||
trimmed = trimmed[:16000]
|
||||
}
|
||||
state = map[string]any{
|
||||
"review_input_json": trimmed,
|
||||
"policy": policy,
|
||||
"truncated": true,
|
||||
}
|
||||
}
|
||||
return Request{
|
||||
Model: model,
|
||||
State: state,
|
||||
Questions: map[string]ChoiceQuestion{
|
||||
"decision": {
|
||||
Type: "choice",
|
||||
Instructions: "Choose the action for this tool call. Apply the supplied policy and evidence.",
|
||||
Criteria: map[string]string{
|
||||
"allow": "Safe to execute without human review",
|
||||
"ask": "Needs human approval before executing",
|
||||
"deny": "Must be blocked",
|
||||
},
|
||||
},
|
||||
},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c Config) Do(ctx context.Context, req Request) (Response, error) {
|
||||
endpoint, err := c.endpoint()
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
body, err := json.Marshal(req)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
hc := c.Client
|
||||
if hc == nil {
|
||||
hc = http.DefaultClient
|
||||
}
|
||||
hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
hreq.Header.Set("Authorization", "Bearer "+strings.TrimSpace(c.APIKey))
|
||||
hreq.Header.Set("Content-Type", "application/json")
|
||||
resp, err := hc.Do(hreq)
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
raw, err := io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
if err != nil {
|
||||
return Response{}, err
|
||||
}
|
||||
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||
return Response{}, fmt.Errorf("System One HTTP %d: %s", resp.StatusCode, strings.TrimSpace(string(raw)))
|
||||
}
|
||||
var out Response
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return Response{}, fmt.Errorf("System One 响应无效: %w", err)
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func DecisionFromResponse(resp Response, threshold float64, profileID int64) (intercept.Decision, error) {
|
||||
if threshold <= 0 || threshold > 1 {
|
||||
threshold = 0.55
|
||||
}
|
||||
a, ok := resp.Answers["decision"]
|
||||
if !ok {
|
||||
return intercept.Decision{ProfileID: profileID}, fmt.Errorf("System One 缺少 decision 答案")
|
||||
}
|
||||
choice := strings.ToLower(strings.TrimSpace(a.Choice))
|
||||
if choice != "allow" && choice != "ask" && choice != "deny" {
|
||||
return intercept.Decision{ProfileID: profileID}, fmt.Errorf("System One 返回了无效裁决 %q", a.Choice)
|
||||
}
|
||||
if a.Confidence < threshold {
|
||||
choice = "ask"
|
||||
}
|
||||
return intercept.Decision{
|
||||
Action: choice,
|
||||
ProfileID: profileID,
|
||||
Message: FormatMessage(choice, a.Confidence, a.Probabilities),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func FormatMessage(choice string, confidence float64, probabilities map[string]float64) string {
|
||||
parts := make([]string, 0, 3)
|
||||
for _, k := range []string{"allow", "ask", "deny"} {
|
||||
if p, ok := probabilities[k]; ok {
|
||||
parts = append(parts, fmt.Sprintf("%s %.0f%%", k, p*100))
|
||||
}
|
||||
}
|
||||
if len(parts) == 0 {
|
||||
return fmt.Sprintf("Jev / System One 判定:%s(置信度 %.0f%%)", choice, confidence*100)
|
||||
}
|
||||
return fmt.Sprintf("Jev / System One 判定:%s(置信度 %.0f%%;概率 %s)", choice, confidence*100, strings.Join(parts, ","))
|
||||
}
|
||||
|
||||
func (c Config) Review(ctx context.Context, input intercept.ReviewInput, prompt string, threshold float64, profileID int64) (intercept.Decision, error) {
|
||||
req, err := BuildRequest(input, prompt, c.Model)
|
||||
if err != nil {
|
||||
return intercept.Decision{ProfileID: profileID}, err
|
||||
}
|
||||
resp, err := c.Do(ctx, req)
|
||||
if err != nil {
|
||||
return intercept.Decision{ProfileID: profileID}, err
|
||||
}
|
||||
return DecisionFromResponse(resp, threshold, profileID)
|
||||
}
|
||||
|
||||
func (c Config) TestConnection(ctx context.Context) (time.Duration, string, error) {
|
||||
start := time.Now()
|
||||
req, err := BuildRequest(intercept.ReviewInput{Version: 5, Tool: "connection_test", Arguments: json.RawMessage(`{}`)}, "Reply with the safest choice for this harmless connection test.", c.Model)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
resp, err := c.Do(ctx, req)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
a, err := DecisionFromResponse(resp, 0, 0)
|
||||
if err != nil {
|
||||
return 0, "", err
|
||||
}
|
||||
return time.Since(start), a.Message, nil
|
||||
}
|
||||
@@ -1,98 +0,0 @@
|
||||
package jev
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
)
|
||||
|
||||
func TestBuildRequestMapsReviewInputAndChoiceCriteria(t *testing.T) {
|
||||
req, err := BuildRequest(intercept.ReviewInput{Version: 5, Tool: "Bash", Arguments: json.RawMessage(`{"cmd":"ls"}`)}, "policy text", "jev-test")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if req.Model != "jev-test" || req.Questions["decision"].Type != "choice" {
|
||||
t.Fatalf("unexpected request: %#v", req)
|
||||
}
|
||||
b, _ := json.Marshal(req.State)
|
||||
if !strings.Contains(string(b), "Bash") || !strings.Contains(string(b), "policy text") {
|
||||
t.Fatalf("state did not include review and policy: %s", b)
|
||||
}
|
||||
if len(b) > maxStateBytes+2000 {
|
||||
t.Fatalf("state exceeded bound: %d", len(b))
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRequestBoundsLargeEvidence(t *testing.T) {
|
||||
args := json.RawMessage(`{"payload":"` + strings.Repeat("x", 50000) + `"}`)
|
||||
req, err := BuildRequest(intercept.ReviewInput{Version: 5, Tool: "Bash", Arguments: args}, strings.Repeat("policy ", 3000), "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, _ := json.Marshal(req.State)
|
||||
if len(b) > maxStateBytes+500 {
|
||||
t.Fatalf("bounded state size=%d", len(b))
|
||||
}
|
||||
if !strings.Contains(string(b), `"truncated":true`) {
|
||||
t.Fatalf("large evidence was not marked truncated: %s", b[:min(len(b), 200)])
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecisionFromResponseLowConfidenceForcesAsk(t *testing.T) {
|
||||
d, err := DecisionFromResponse(Response{Answers: map[string]Answer{
|
||||
"decision": {Choice: "allow", Confidence: 0.3, Probabilities: map[string]float64{"allow": 0.8, "ask": 0.1, "deny": 0.1}},
|
||||
}}, 0.55, 42)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if d.Action != "ask" || d.ProfileID != 42 || !strings.Contains(d.Message, "置信度") {
|
||||
t.Fatalf("unexpected decision: %#v", d)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecisionFromResponseRejectsMissingOrInvalidAnswer(t *testing.T) {
|
||||
for _, resp := range []Response{
|
||||
{Answers: map[string]Answer{}},
|
||||
{Answers: map[string]Answer{"decision": {Choice: "maybe", Confidence: 1}}},
|
||||
} {
|
||||
if _, err := DecisionFromResponse(resp, 0.55, 1); err == nil {
|
||||
t.Fatalf("expected invalid response error for %#v", resp)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSystemOneReviewAndConnectionTest(t *testing.T) {
|
||||
var gotAuth string
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/v1/systemone" {
|
||||
t.Errorf("path=%s", r.URL.Path)
|
||||
}
|
||||
gotAuth = r.Header.Get("Authorization")
|
||||
var req Request
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
t.Errorf("decode: %v", err)
|
||||
}
|
||||
if req.Questions["decision"].Criteria["deny"] == "" {
|
||||
t.Error("missing deny criterion")
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_, _ = w.Write([]byte(`{"answers":{"decision":{"type":"choice","choice":"deny","confidence":0.91,"probabilities":{"allow":0.02,"ask":0.07,"deny":0.91}}}}`))
|
||||
}))
|
||||
defer srv.Close()
|
||||
c := Config{BaseURL: srv.URL, APIKey: "secret", Model: "jev-latest", Client: srv.Client()}
|
||||
d, err := c.Review(context.Background(), intercept.ReviewInput{Version: 5, Tool: "Write", Arguments: json.RawMessage(`{}`)}, "policy", 0.55, 9)
|
||||
if err != nil || d.Action != "deny" {
|
||||
t.Fatalf("review decision=%#v err=%v", d, err)
|
||||
}
|
||||
if gotAuth != "Bearer secret" {
|
||||
t.Fatalf("auth=%q", gotAuth)
|
||||
}
|
||||
if _, _, err := c.TestConnection(context.Background()); err != nil {
|
||||
t.Fatalf("connection test: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -233,13 +233,6 @@ func wireTools(pg *db.DB, domainReg map[string]actool.CoreTool) {
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if item.Kind == "credential" {
|
||||
// The durable database row is the source of truth; this wake-up only
|
||||
// reduces latency. The recovery ticker handles missed notifications.
|
||||
if agent.CredentialRecorded != nil {
|
||||
agent.CredentialRecorded()
|
||||
}
|
||||
}
|
||||
return item.ID, nil
|
||||
}
|
||||
// Seed the built-in domain tools (first-insert only; DO NOTHING preserves edits).
|
||||
|
||||
+1
-17
@@ -103,10 +103,6 @@ func (s *Server) pgCreateConversation(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if req.LLMProfileID != nil {
|
||||
if p, err := pg.ProfileByID(*req.LLMProfileID); err == nil && p != nil && p.Format == "jev" {
|
||||
writeErr(w, 400, db.ErrJevProfileJudgeOnly.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.loadProfileConfig(*req.LLMProfileID); !ok {
|
||||
writeErr(w, 400, "指定的 LLM 配置不存在或未设置 API Key")
|
||||
return
|
||||
@@ -140,10 +136,6 @@ func (s *Server) pgUpdateConversation(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if req.LLMProfileID != nil {
|
||||
if p, err := pg.ProfileByID(*req.LLMProfileID); err == nil && p != nil && p.Format == "jev" {
|
||||
writeErr(w, 400, db.ErrJevProfileJudgeOnly.Error())
|
||||
return
|
||||
}
|
||||
if _, ok := s.loadProfileConfig(*req.LLMProfileID); !ok {
|
||||
writeErr(w, 400, "指定的 LLM 配置不存在或未设置 API Key")
|
||||
return
|
||||
@@ -709,14 +701,6 @@ func (s *Server) readTriggerBehavior(agentKey string) triggerBehavior {
|
||||
// merging by task / all / none); parallel → run each fire in its own concurrent
|
||||
// conversation up to trigger_max_parallel. Distinct agents always run concurrently.
|
||||
func (s *Server) StartTriggeredRun(agentKey, title, message string, taskID int64, mergeable bool, taskDesc, taskGoal string) {
|
||||
s.startTriggeredRun(agentKey, title, message, taskID, mergeable, taskDesc, taskGoal, 0)
|
||||
}
|
||||
|
||||
func (s *Server) StartTriggeredRunWithConversation(agentKey, title, message string, taskID int64, mergeable bool, taskDesc, taskGoal string, conversationID int64) {
|
||||
s.startTriggeredRun(agentKey, title, message, taskID, mergeable, taskDesc, taskGoal, conversationID)
|
||||
}
|
||||
|
||||
func (s *Server) startTriggeredRun(agentKey, title, message string, taskID int64, mergeable bool, taskDesc, taskGoal string, conversationID int64) {
|
||||
if s.m.pg == nil || s.chatAgentRef() == nil {
|
||||
return
|
||||
}
|
||||
@@ -730,7 +714,7 @@ func (s *Server) startTriggeredRun(agentKey, title, message string, taskID int64
|
||||
cfg := s.readTriggerBehavior(agentKey) // DB read BEFORE the lock (never under queueMu)
|
||||
s.queueMu.Lock()
|
||||
s.triggerCfg[agentKey] = cfg
|
||||
s.triggerQ[agentKey] = append(s.triggerQ[agentKey], triggeredRun{conversationID: conversationID, agentKey: agentKey, title: title, message: message, taskID: taskID, taskDesc: taskDesc, taskGoal: taskGoal, mergeable: mergeable})
|
||||
s.triggerQ[agentKey] = append(s.triggerQ[agentKey], triggeredRun{agentKey: agentKey, title: title, message: message, taskID: taskID, taskDesc: taskDesc, taskGoal: taskGoal, mergeable: mergeable})
|
||||
s.pumpLocked(agentKey)
|
||||
s.queueMu.Unlock()
|
||||
}
|
||||
|
||||
+1
-25
@@ -2,7 +2,6 @@ package server
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"log"
|
||||
"net/http"
|
||||
@@ -42,16 +41,13 @@ func (s *Server) seedCredentialProcessor() {
|
||||
_ = s.m.pg.SetBool(flag, true)
|
||||
}
|
||||
func (s *Server) runCredentialProcessor() {
|
||||
// Events wake the worker immediately. The slow ticker is only a recovery
|
||||
// scan for rows created before a notification or after a process restart.
|
||||
ticker := time.NewTicker(30 * time.Second)
|
||||
ticker := time.NewTicker(3 * time.Second)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-s.ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
case <-s.credentialWake:
|
||||
}
|
||||
if !s.m.pg.GetBool(db.SettingTaskIntelligence, false) || !s.m.pg.GetBool(db.SettingCredentialAgent, false) {
|
||||
continue
|
||||
@@ -97,14 +93,6 @@ func (s *Server) processCredentialJob(parent context.Context, item *db.Intellige
|
||||
}
|
||||
}()
|
||||
result := *item.Credentials
|
||||
conversationID := result.ConversationID
|
||||
if conversationID == 0 {
|
||||
if c, err := s.m.pg.CreateConversation(db.CredentialAgentKey, "凭据整理 · "+firstLine(item.Title, 42), nil); err == nil {
|
||||
conversationID = c.ID
|
||||
result.ConversationID = c.ID
|
||||
_, _ = s.m.pg.AppendConvActivity(c.ID, db.Activity{Worker: db.CredentialAgentKey, Kind: "user", Summary: "收到凭据上报,开始整理", Detail: item.Content})
|
||||
}
|
||||
}
|
||||
needsExtract := result.ForceExtract || result.SourceHash != db.CredentialSourceHash(item.Content) || result.Method == "" || (result.Error != "" && len(result.Accounts) == 0)
|
||||
result.State = "ready"
|
||||
result.ForceExtract = false
|
||||
@@ -197,20 +185,8 @@ func (s *Server) processCredentialJob(parent context.Context, item *db.Intellige
|
||||
if err := s.m.pg.FinishCredentialJob(finish, item, result); err != nil {
|
||||
log.Printf("[credentials] save record #%d: %v", item.ID, err)
|
||||
}
|
||||
if conversationID > 0 {
|
||||
detail, _ := json.Marshal(result)
|
||||
kind, summary := "result", "凭据整理完成"
|
||||
isError := result.State == "error"
|
||||
if isError {
|
||||
kind, summary = "text", "凭据整理失败:"+result.Error
|
||||
}
|
||||
_, _ = s.m.pg.AppendConvActivity(conversationID, db.Activity{Worker: db.CredentialAgentKey, Kind: kind, IsError: isError, Summary: summary, Detail: string(detail), InputTokens: intPtr(result.InputTokens), OutputTokens: intPtr(result.OutputTokens)})
|
||||
_ = s.m.pg.TouchConversation(conversationID)
|
||||
}
|
||||
}
|
||||
|
||||
func intPtr(v int) *int { return &v }
|
||||
|
||||
var credentialURLs = regexp.MustCompile(`https?://[^\s<>"',;))]+`)
|
||||
var credentialTrafficIDs = regexp.MustCompile(`\b[0-9]{8,}-[0-9]{3,}\b`)
|
||||
|
||||
|
||||
+17
-19
@@ -325,16 +325,15 @@ type FindingDTO struct {
|
||||
ReportStale bool `json:"report_stale"`
|
||||
TrafficBindings []db.FindingTrafficBinding `json:"traffic_bindings,omitempty"`
|
||||
|
||||
ID string `json:"id"`
|
||||
FindingID string `json:"finding_id,omitempty"` // standalone findings-table id — the handle for status updates
|
||||
VulnClass string `json:"vulnclass"`
|
||||
Name string `json:"name,omitempty"` // 漏洞名称;为空时前端回退展示 vulnclass
|
||||
Severity string `json:"severity"` // critical | high | medium | low
|
||||
Status string `json:"status"` // pending | in_progress | confirmed | resolved | fixed | false_positive | ignored | duplicate | risk_accepted
|
||||
Summary string `json:"summary"`
|
||||
Evidence string `json:"evidence"`
|
||||
Report string `json:"report,omitempty"` // 详细报告(Markdown);仅详情接口返回,列表为空
|
||||
ReportConversationID *int64 `json:"report_conversation_id,omitempty"`
|
||||
ID string `json:"id"`
|
||||
FindingID string `json:"finding_id,omitempty"` // standalone findings-table id — the handle for status updates
|
||||
VulnClass string `json:"vulnclass"`
|
||||
Name string `json:"name,omitempty"` // 漏洞名称;为空时前端回退展示 vulnclass
|
||||
Severity string `json:"severity"` // critical | high | medium | low
|
||||
Status string `json:"status"` // pending | in_progress | confirmed | resolved | fixed | false_positive | ignored | duplicate | risk_accepted
|
||||
Summary string `json:"summary"`
|
||||
Evidence string `json:"evidence"`
|
||||
Report string `json:"report,omitempty"` // 详细报告(Markdown);仅详情接口返回,列表为空
|
||||
|
||||
IntentID string `json:"intent_id,omitempty"`
|
||||
ParamID string `json:"param_id,omitempty"`
|
||||
@@ -465,15 +464,14 @@ func findingFromDB(f *db.DBFinding, assets map[int64]*db.Asset) FindingDTO {
|
||||
FindingID: i64s(f.ID),
|
||||
TrafficCount: f.TrafficCount, EvidenceVersion: f.EvidenceVersion, ReportEvidenceVersion: f.ReportEvidenceVersion,
|
||||
ReportStale: f.Report != "" && f.EvidenceVersion != f.ReportEvidenceVersion, TrafficBindings: f.TrafficBindings,
|
||||
VulnClass: f.VulnClass,
|
||||
Name: f.Name,
|
||||
Severity: f.Severity,
|
||||
Status: status,
|
||||
Summary: f.Summary,
|
||||
Evidence: f.Evidence,
|
||||
Report: f.Report,
|
||||
ReportConversationID: f.ReportConversationID,
|
||||
TS: rfc3339(f.CreatedAt),
|
||||
VulnClass: f.VulnClass,
|
||||
Name: f.Name,
|
||||
Severity: f.Severity,
|
||||
Status: status,
|
||||
Summary: f.Summary,
|
||||
Evidence: f.Evidence,
|
||||
Report: f.Report,
|
||||
TS: rfc3339(f.CreatedAt),
|
||||
}
|
||||
if f.ExportCaseID > 0 {
|
||||
d.ID = "case:" + i64s(f.ExportCaseID)
|
||||
|
||||
@@ -58,30 +58,20 @@ func (s *Server) generateFindingReport(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
message := manualFindingReportMessage(f, taskID)
|
||||
conversation, err := s.m.pg.CreateConversation("reporter", fmt.Sprintf("漏洞报告 · finding#%d", f.ID), nil)
|
||||
if err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "创建报告对话失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
if err := s.m.pg.SetFindingReportConversationID(f.ID, conversation.ID); err != nil {
|
||||
writeErr(w, http.StatusInternalServerError, "关联报告对话失败: "+err.Error())
|
||||
return
|
||||
}
|
||||
s.StartTriggeredRunWithConversation(
|
||||
s.StartTriggeredRun(
|
||||
"reporter",
|
||||
fmt.Sprintf("人工生成步骤与报告 · finding#%d", f.ID),
|
||||
message,
|
||||
taskID,
|
||||
false,
|
||||
taskDesc,
|
||||
taskGoal, conversation.ID,
|
||||
taskGoal,
|
||||
)
|
||||
writeJSON(w, http.StatusAccepted, map[string]any{
|
||||
"queued": true,
|
||||
"agent_key": "reporter",
|
||||
"finding_id": f.ID,
|
||||
"finding_node_id": *f.NodeID,
|
||||
"conversation_id": conversation.ID,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -16,7 +16,6 @@ import (
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/guard"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/artex/intercept/jev"
|
||||
"github.com/Autumn-27/artex/llmrec"
|
||||
"github.com/Autumn-27/norma/llm"
|
||||
)
|
||||
@@ -45,17 +44,6 @@ func (s *Server) wireInterceptReviewer() {
|
||||
if profileID == 0 {
|
||||
return intercept.Decision{}, fmt.Errorf("未配置可用的裁判模型")
|
||||
}
|
||||
profile, err := s.m.pg.ProfileByID(profileID)
|
||||
if err != nil || profile == nil {
|
||||
return intercept.Decision{ProfileID: profileID}, fmt.Errorf("裁判模型 profile %d 不可用", profileID)
|
||||
}
|
||||
if profile.Format == "jev" {
|
||||
if strings.TrimSpace(profile.APIKey) == "" {
|
||||
return intercept.Decision{ProfileID: profileID}, fmt.Errorf("Jev 判定模型未设置 API Key")
|
||||
}
|
||||
ctx = llmrec.WithWorker(ctx, judgeWorkerLane)
|
||||
return jev.Config{BaseURL: profile.BaseURL, APIKey: profile.APIKey, Model: profile.Model}.Review(ctx, input, prompt, s.m.interceptor.GetJudgeConfig().ConfidenceThreshold, profileID)
|
||||
}
|
||||
prov, _, ok := s.providerForProfile(profileID)
|
||||
if !ok {
|
||||
return intercept.Decision{ProfileID: profileID}, fmt.Errorf("裁判模型 profile %d 不可用", profileID)
|
||||
@@ -490,9 +478,6 @@ func (s *Server) interceptSetJudgeConfig(w http.ResponseWriter, r *http.Request)
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if req.ConfidenceThreshold <= 0 || req.ConfidenceThreshold > 1 {
|
||||
req.ConfidenceThreshold = 0.55
|
||||
}
|
||||
if err := s.m.interceptor.SetJudgeConfig(req); err != nil {
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
|
||||
@@ -11,7 +11,6 @@ import (
|
||||
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/norma/permission"
|
||||
actool "github.com/Autumn-27/norma/tool"
|
||||
)
|
||||
@@ -475,16 +474,6 @@ func (s *Server) toolUpdateFindingReport() actool.CoreTool {
|
||||
if err := s.checkCaseReviewScope(ctx, "update_finding_report", findingCaseRequest{FindingID: json.RawMessage(i64s(fid))}); err != nil {
|
||||
return actool.Errorf(err.Error()), nil
|
||||
}
|
||||
if f, err := s.m.pg.GetFinding(fid); err == nil && f != nil && f.TaskID != nil {
|
||||
if err := s.archiveFindingScreenshots(*f.TaskID, a.Report); err != nil {
|
||||
return actool.Errorf("归档报告截图失败: " + err.Error()), nil
|
||||
}
|
||||
}
|
||||
if conversationID := intercept.ConvIDFromContext(ctx); conversationID > 0 {
|
||||
if err := s.m.pg.SetFindingReportConversationID(fid, conversationID); err != nil {
|
||||
return actool.Errorf("关联报告对话失败: " + err.Error()), nil
|
||||
}
|
||||
}
|
||||
}
|
||||
n, err := s.m.pg.SetFindingReportVersionByNodeID(ctx, nodeID, a.Report, a.EvidenceVersion)
|
||||
if err != nil {
|
||||
|
||||
+8
-68
@@ -22,7 +22,6 @@ import (
|
||||
"github.com/Autumn-27/artex/agent"
|
||||
"github.com/Autumn-27/artex/db"
|
||||
"github.com/Autumn-27/artex/intercept"
|
||||
"github.com/Autumn-27/artex/intercept/jev"
|
||||
"github.com/Autumn-27/artex/llmpool"
|
||||
"github.com/Autumn-27/artex/llmrec"
|
||||
"github.com/Autumn-27/artex/report"
|
||||
@@ -44,10 +43,6 @@ type Server struct {
|
||||
m *Manager
|
||||
engine *Engine
|
||||
ctx context.Context
|
||||
dataDir string
|
||||
metricsMu sync.Mutex
|
||||
lastCPUUse uint64
|
||||
lastCPUAt time.Time
|
||||
|
||||
skillDir string // root directory for skill subdirectories
|
||||
jwtKey []byte // HS256 signing key loaded from / generated into dataDir/jwt.key
|
||||
@@ -126,10 +121,9 @@ type Server struct {
|
||||
|
||||
// Cold task archives run through one persistent FIFO worker. The buffered wake
|
||||
// channel coalesces enqueue bursts; the database remains the source of truth.
|
||||
archiveWake chan struct{}
|
||||
archiveWG sync.WaitGroup
|
||||
credentialWake chan struct{}
|
||||
side *sideQuestionState
|
||||
archiveWake chan struct{}
|
||||
archiveWG sync.WaitGroup
|
||||
side *sideQuestionState
|
||||
}
|
||||
|
||||
// provEntry is a cached provider + its config for one LLM profile id.
|
||||
@@ -158,12 +152,12 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
if err != nil {
|
||||
log.Fatalf("[auth] JWT key: %v", err)
|
||||
}
|
||||
s := &Server{m: m, engine: NewEngine(m), ctx: ctx, skillDir: skillDir, dataDir: dataDir, jwtKey: key, chatBusy: map[string]bool{},
|
||||
s := &Server{m: m, engine: NewEngine(m), ctx: ctx, skillDir: skillDir, jwtKey: key, chatBusy: map[string]bool{},
|
||||
chatCancel: map[string]context.CancelCauseFunc{}, triggerQ: map[string][]triggeredRun{},
|
||||
triggerActive: map[string]int{}, triggerCfg: map[string]triggerBehavior{},
|
||||
profChatAgents: map[int64]*agent.ChatAgent{},
|
||||
provByProfile: map[int64]*provEntry{}, llmHealth: newLLMHealthRegistry(m.pg),
|
||||
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1), credentialWake: make(chan struct{}, 1)}
|
||||
taskAgents: map[string]*taskAgentBundle{}, archiveWake: make(chan struct{}, 1)}
|
||||
s.initSideQuestions()
|
||||
if m.pg != nil {
|
||||
if err := s.basicAuth.load(m.pg); err != nil {
|
||||
@@ -284,12 +278,6 @@ func New(ctx context.Context, m *Manager, skillDir string, dataDir string, keyDi
|
||||
go s.reconcileConcurrency()
|
||||
s.startTaskArchiveWorker()
|
||||
if m.pg != nil {
|
||||
agent.CredentialRecorded = func() {
|
||||
select {
|
||||
case s.credentialWake <- struct{}{}:
|
||||
default:
|
||||
}
|
||||
}
|
||||
s.seedCredentialProcessor()
|
||||
go s.runCredentialProcessor()
|
||||
}
|
||||
@@ -384,9 +372,8 @@ func (s *Server) loadLLMConfig() (agent.Config, bool) {
|
||||
|
||||
// saveLLMConfig persists the LLM config as the active "default" profile in PG.
|
||||
func (s *Server) saveLLMConfig(cfg agent.Config) error {
|
||||
// cfg.Provider() returns one of the chat format strings
|
||||
// (anthropic / openai / openai-responses), matching the DB CHECK constraint;
|
||||
// Jev is intentionally handled by the dedicated intercept path.
|
||||
// cfg.Provider() already returns one of the three valid format strings
|
||||
// (anthropic / openai / openai-responses), matching the DB CHECK constraint.
|
||||
format := cfg.Provider()
|
||||
var id int64
|
||||
// 这个 legacy 端点的请求体不含轮询/收发/输出上限参数,故把库里已存的值原样带回 —
|
||||
@@ -466,9 +453,6 @@ func maxTokensResolver(cfg agent.Config) func() int {
|
||||
// applyLLM (re)builds the planner/worker/main-agent from cfg and installs them on
|
||||
// the running engine as the GLOBAL active pair. Safe to call at runtime (UI configures LLM).
|
||||
func (s *Server) applyLLM(cfg agent.Config) error {
|
||||
if cfg.Jev {
|
||||
return fmt.Errorf("Jev System One 仅可用于拦截判定模型,不能作为聊天 provider")
|
||||
}
|
||||
var prov llm.Provider
|
||||
// A persisted active profile must use the same cached provider as task chains
|
||||
// and Agent bindings, otherwise each path owns a separate rate limiter.
|
||||
@@ -539,9 +523,6 @@ func (s *Server) loadProfileConfig(id int64) (agent.Config, bool) {
|
||||
if err != nil || p == nil {
|
||||
return agent.Config{}, false
|
||||
}
|
||||
if p.Format == "jev" {
|
||||
return agent.Config{}, false
|
||||
}
|
||||
cfg := agent.ConfigFrom(p.Format, p.Model, p.BaseURL, p.APIKey, p.Proxy)
|
||||
cfg.RatePerSecond, cfg.RatePerMinute = p.RatePerSecond, p.RatePerMinute
|
||||
cfg.ContextWindowK = p.ContextWindowK
|
||||
@@ -607,9 +588,6 @@ func (s *Server) chatUnavailableReason() string {
|
||||
// ok=false when the profile is missing/invalid; pinned callers surface that error
|
||||
// while legacy callers retain their existing fallback behavior.
|
||||
func (s *Server) providerForProfile(id int64) (llm.Provider, agent.Config, bool) {
|
||||
if p, err := s.m.pg.ProfileByID(id); err != nil || p == nil || p.Format == "jev" {
|
||||
return nil, agent.Config{}, false
|
||||
}
|
||||
s.provCacheMu.Lock()
|
||||
if e := s.provByProfile[id]; e != nil {
|
||||
s.provCacheMu.Unlock()
|
||||
@@ -735,7 +713,6 @@ func (s *Server) Handler() http.Handler {
|
||||
|
||||
mux.HandleFunc("GET /api/health", s.health)
|
||||
mux.HandleFunc("GET /api/stats", s.stats)
|
||||
mux.HandleFunc("GET /api/system/metrics", s.systemMetrics)
|
||||
mux.HandleFunc("GET /api/logs", s.getLogs)
|
||||
mux.HandleFunc("GET /api/logs/history", s.getLogsHistory)
|
||||
mux.HandleFunc("GET /api/logs/stream", s.streamLogs)
|
||||
@@ -1510,37 +1487,6 @@ func (s *Server) testLLM(w http.ResponseWriter, r *http.Request) {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if strings.TrimSpace(req.Provider) == "jev" {
|
||||
apiKey := strings.TrimSpace(req.APIKey)
|
||||
baseURL, model := strings.TrimSpace(req.BaseURL), strings.TrimSpace(req.Model)
|
||||
if req.ProfileID != nil {
|
||||
if p, err := s.m.pg.ProfileByID(*req.ProfileID); err == nil && p != nil {
|
||||
if apiKey == "" {
|
||||
apiKey = p.APIKey
|
||||
}
|
||||
if baseURL == "" {
|
||||
baseURL = p.BaseURL
|
||||
}
|
||||
if model == "" {
|
||||
model = p.Model
|
||||
}
|
||||
}
|
||||
}
|
||||
if apiKey == "" {
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": "未提供 API Key"})
|
||||
return
|
||||
}
|
||||
if model == "" {
|
||||
model = jev.DefaultModel
|
||||
}
|
||||
lat, reply, err := (jev.Config{BaseURL: baseURL, APIKey: apiKey, Model: model}).TestConnection(r.Context())
|
||||
if err != nil {
|
||||
writeJSON(w, 200, map[string]any{"ok": false, "error": err.Error()})
|
||||
return
|
||||
}
|
||||
writeJSON(w, 200, map[string]any{"ok": true, "latency_ms": lat.Milliseconds(), "model": model, "reply": truncateReply(reply)})
|
||||
return
|
||||
}
|
||||
cfg := agent.ConfigFrom(req.Provider, req.Model, req.BaseURL, req.APIKey, req.Proxy)
|
||||
// mirror production: send the SAME thinking params so a provider that rejects the
|
||||
// reasoning_effort/thinking field fails the test too (no false "test ok, run 400").
|
||||
@@ -1735,9 +1681,6 @@ func (s *Server) validateTaskProfileIDs(ids []int64) error {
|
||||
return fmt.Errorf("LLM 配置 id 无效或重复")
|
||||
}
|
||||
seen[id] = true
|
||||
if p, err := s.m.pg.ProfileByID(id); err == nil && p != nil && p.Format == "jev" {
|
||||
return db.ErrJevProfileJudgeOnly
|
||||
}
|
||||
if _, ok := s.loadProfileConfig(id); !ok {
|
||||
return fmt.Errorf("LLM 配置 #%d 不存在或未设置 API Key", id)
|
||||
}
|
||||
@@ -2377,10 +2320,7 @@ func (s *Server) findingsExport(w http.ResponseWriter, r *http.Request) {
|
||||
stamp := now.Format("20060102-150405")
|
||||
setDownload := func(contentType, filename string) {
|
||||
w.Header().Set("Content-Type", contentType)
|
||||
// Keep an ASCII fallback for older clients and provide the real UTF-8
|
||||
// name through RFC 5987. Browsers otherwise commonly decode Chinese
|
||||
// names using the platform legacy code page.
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"; filename*=UTF-8''%s`, sanitizeFilename(filename), url.PathEscape(filename)))
|
||||
w.Header().Set("Content-Disposition", fmt.Sprintf(`attachment; filename="%s"`, filename))
|
||||
}
|
||||
|
||||
switch format {
|
||||
|
||||
@@ -386,10 +386,6 @@ func (s *Server) pgSaveAgentConfig(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
}
|
||||
if err := pg.SetAgentLLMProfile(a.Key, id); err != nil {
|
||||
if errors.Is(err, db.ErrJevProfileJudgeOnly) {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
@@ -491,9 +487,6 @@ func (s *Server) pgGetAgent(w http.ResponseWriter, r *http.Request) {
|
||||
profs, _ := pg.ListProfiles()
|
||||
llmProfiles := make([]map[string]any, 0, len(profs))
|
||||
for _, p := range profs {
|
||||
if p.Format == "jev" {
|
||||
continue
|
||||
}
|
||||
llmProfiles = append(llmProfiles, map[string]any{
|
||||
"id": p.ID, "name": p.Name, "model": p.Model, "is_default": p.IsDefault,
|
||||
})
|
||||
@@ -1942,14 +1935,6 @@ func (s *Server) pgActivateProfile(w http.ResponseWriter, r *http.Request) {
|
||||
return
|
||||
}
|
||||
if err := pg.SetActiveProfile(body.ID); err != nil {
|
||||
if errors.Is(err, db.ErrJevProfileJudgeOnly) {
|
||||
writeErr(w, 400, err.Error())
|
||||
return
|
||||
}
|
||||
if errors.Is(err, db.ErrLLMProfileNotFound) {
|
||||
writeErr(w, 404, "LLM 配置不存在")
|
||||
return
|
||||
}
|
||||
writeErr(w, 500, err.Error())
|
||||
return
|
||||
}
|
||||
|
||||
@@ -1,31 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
type metricValue struct {
|
||||
UsedBytes uint64 `json:"used_bytes"`
|
||||
TotalBytes uint64 `json:"total_bytes"`
|
||||
Percent float64 `json:"percent"`
|
||||
}
|
||||
|
||||
type systemMetricsResponse struct {
|
||||
Scope string `json:"scope"`
|
||||
Supported bool `json:"supported"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
CPU metricValue `json:"cpu"`
|
||||
Memory metricValue `json:"memory"`
|
||||
Disk metricValue `json:"disk"`
|
||||
DataDisk metricValue `json:"data_disk"`
|
||||
Container bool `json:"container_metrics"`
|
||||
}
|
||||
|
||||
func (s *Server) systemMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
out := systemMetricsSnapshot(s)
|
||||
w.Header().Set("Cache-Control", "no-store")
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
@@ -1,62 +0,0 @@
|
||||
//go:build linux
|
||||
|
||||
package server
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"syscall"
|
||||
"time"
|
||||
)
|
||||
|
||||
func systemMetricsSnapshot(s *Server) systemMetricsResponse {
|
||||
use, limit, cpuOK := cgroupCPU()
|
||||
cpuPct := 0.0
|
||||
if cpuOK {
|
||||
now := time.Now()
|
||||
s.metricsMu.Lock()
|
||||
if !s.lastCPUAt.IsZero() && use >= s.lastCPUUse && limit > 0 {
|
||||
elapsed := now.Sub(s.lastCPUAt).Seconds()
|
||||
if elapsed > 0 { cpuPct = float64(use-s.lastCPUUse) / 1e6 / elapsed / limit * 100 }
|
||||
}
|
||||
s.lastCPUUse, s.lastCPUAt = use, now
|
||||
s.metricsMu.Unlock()
|
||||
}
|
||||
memUsed, memTotal, memOK := cgroupMemory()
|
||||
root, data := diskMetric("/"), diskMetric(s.dataDir)
|
||||
if data.TotalBytes == 0 { data = root }
|
||||
return systemMetricsResponse{Scope: "container", Supported: true, Timestamp: time.Now().UTC(), Container: cpuOK || memOK,
|
||||
CPU: metricValue{Percent: clampPercent(cpuPct)}, Memory: metricValue{UsedBytes: memUsed, TotalBytes: memTotal, Percent: percent(memUsed, memTotal)}, Disk: root, DataDisk: data}
|
||||
}
|
||||
|
||||
func cgroupCPU() (uint64, float64, bool) {
|
||||
for _, dir := range []string{"/sys/fs/cgroup", "/sys/fs/cgroup/cpu"} {
|
||||
b, err := os.ReadFile(filepath.Join(dir, "cpu.stat")); if err != nil { continue }
|
||||
var use uint64
|
||||
for _, line := range strings.Split(string(b), "\n") { f := strings.Fields(line); if len(f) == 2 && f[0] == "usage_usec" { use, _ = strconv.ParseUint(f[1], 10, 64) } }
|
||||
limit := float64(runtime.NumCPU())
|
||||
if q, err := os.ReadFile(filepath.Join(dir, "cpu.max")); err == nil { f := strings.Fields(string(q)); if len(f) == 2 && f[0] != "max" { quota, _ := strconv.ParseFloat(f[0], 64); period, _ := strconv.ParseFloat(f[1], 64); if period > 0 { limit = quota / period } } }
|
||||
if use > 0 { return use, limit, true }
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func cgroupMemory() (uint64, uint64, bool) {
|
||||
for _, dir := range []string{"/sys/fs/cgroup", "/sys/fs/cgroup/memory"} {
|
||||
cur, ce := readUint(filepath.Join(dir, "memory.current")); max, me := readUint(filepath.Join(dir, "memory.max"))
|
||||
if ce == nil && me == nil { return cur, max, max > 0 }
|
||||
if ce == nil { if b, err := os.ReadFile(filepath.Join(dir, "memory.max")); err == nil && strings.TrimSpace(string(b)) == "max" { return cur, hostMemoryTotal(), true } }
|
||||
cur, ce = readUint(filepath.Join(dir, "memory.usage_in_bytes")); max, me = readUint(filepath.Join(dir, "memory.limit_in_bytes"))
|
||||
if ce == nil && me == nil && max < ^uint64(0)/2 { return cur, max, true }
|
||||
}
|
||||
return 0, 0, false
|
||||
}
|
||||
|
||||
func hostMemoryTotal() uint64 { b, err := os.ReadFile("/proc/meminfo"); if err != nil { return 0 }; for _, line := range strings.Split(string(b), "\n") { f := strings.Fields(line); if len(f) >= 2 && f[0] == "MemTotal:" { n, _ := strconv.ParseUint(f[1], 10, 64); return n * 1024 } }; return 0 }
|
||||
func readUint(path string) (uint64, error) { b, err := os.ReadFile(path); if err != nil { return 0, err }; return strconv.ParseUint(strings.TrimSpace(string(b)), 10, 64) }
|
||||
func diskMetric(path string) metricValue { var st syscall.Statfs_t; if path == "" || syscall.Statfs(path, &st) != nil { return metricValue{} }; total, free := st.Blocks*uint64(st.Bsize), st.Bavail*uint64(st.Bsize); used := total-free; return metricValue{UsedBytes: used, TotalBytes: total, Percent: percent(used, total)} }
|
||||
func percent(used, total uint64) float64 { if total == 0 { return 0 }; return clampPercent(float64(used)/float64(total)*100) }
|
||||
func clampPercent(v float64) float64 { if v < 0 { return 0 }; if v > 100 { return 100 }; return v }
|
||||
@@ -1,34 +0,0 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http/httptest"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestSystemMetricsReturnsContainerAndDiskValues(t *testing.T) {
|
||||
s := &Server{dataDir: t.TempDir()}
|
||||
r := httptest.NewRequest("GET", "/api/system/metrics", nil)
|
||||
w := httptest.NewRecorder()
|
||||
s.systemMetrics(w, r)
|
||||
if w.Code != 200 {
|
||||
t.Fatalf("status=%d, want 200", w.Code)
|
||||
}
|
||||
var got systemMetricsResponse
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
|
||||
t.Fatalf("decode response: %v", err)
|
||||
}
|
||||
if got.Scope == "" || got.Timestamp.IsZero() {
|
||||
t.Fatalf("unexpected response metadata: %+v", got)
|
||||
}
|
||||
if runtime.GOOS != "linux" {
|
||||
if got.Supported || got.Scope != "unsupported" {
|
||||
t.Fatalf("unsupported platform returned metrics: %+v", got)
|
||||
}
|
||||
return
|
||||
}
|
||||
if got.Disk.TotalBytes == 0 || got.DataDisk.TotalBytes == 0 {
|
||||
t.Fatalf("disk metrics missing: %+v", got)
|
||||
}
|
||||
}
|
||||
@@ -1,9 +0,0 @@
|
||||
//go:build !linux
|
||||
|
||||
package server
|
||||
|
||||
import "time"
|
||||
|
||||
func systemMetricsSnapshot(_ *Server) systemMetricsResponse {
|
||||
return systemMetricsResponse{Scope: "unsupported", Supported: false, Timestamp: time.Now().UTC()}
|
||||
}
|
||||
@@ -2,7 +2,6 @@ package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"io"
|
||||
"mime/multipart"
|
||||
"net/http"
|
||||
@@ -310,67 +309,6 @@ func (s *Server) findSessionPreview(rawPath string) (string, bool) {
|
||||
return newest, newest != ""
|
||||
}
|
||||
|
||||
// archiveFindingScreenshots materializes report-referenced screenshots in the
|
||||
// task workspace. Browser runs use per-session working directories, while
|
||||
// reports intentionally use the portable findings/screenshots/ path.
|
||||
func (s *Server) archiveFindingScreenshots(taskID int64, markdown string) error {
|
||||
if taskID <= 0 {
|
||||
return nil
|
||||
}
|
||||
for _, match := range screenshotMarkdownLink.FindAllStringSubmatch(markdown, -1) {
|
||||
raw := strings.TrimSpace(match[2])
|
||||
rel := filepath.Clean(strings.TrimPrefix(raw, "/"))
|
||||
const prefix = "findings" + string(os.PathSeparator) + "screenshots" + string(os.PathSeparator)
|
||||
if !strings.HasPrefix(rel, prefix) || strings.Contains(rel, ".."+string(os.PathSeparator)) {
|
||||
continue
|
||||
}
|
||||
target := filepath.Join(s.m.dir, "tasks", fmt.Sprint(taskID), rel)
|
||||
if info, err := os.Stat(target); err == nil && !info.IsDir() {
|
||||
continue
|
||||
}
|
||||
source, ok := s.resolveSessionScreenshot(rel)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if err := os.MkdirAll(filepath.Dir(target), 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
in, err := os.Open(source)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
out, err := os.OpenFile(target, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
||||
if err == nil {
|
||||
_, err = io.Copy(out, in)
|
||||
}
|
||||
_ = in.Close()
|
||||
_ = out.Close()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *Server) resolveSessionScreenshot(rel string) (string, bool) {
|
||||
matches, err := filepath.Glob(filepath.Join(filepath.Clean(s.m.dir), "sessions", "*", rel))
|
||||
if err != nil || len(matches) == 0 {
|
||||
return "", false
|
||||
}
|
||||
var newest string
|
||||
var newestTime int64
|
||||
for _, candidate := range matches {
|
||||
info, statErr := os.Stat(candidate)
|
||||
if statErr != nil || info.IsDir() {
|
||||
continue
|
||||
}
|
||||
if newest == "" || info.ModTime().UnixNano() > newestTime {
|
||||
newest, newestTime = candidate, info.ModTime().UnixNano()
|
||||
}
|
||||
}
|
||||
return newest, newest != ""
|
||||
}
|
||||
|
||||
// POST /api/workspace/upload?path=<dir> — multipart form field "file" (one or more).
|
||||
func (s *Server) wsUpload(w http.ResponseWriter, r *http.Request) {
|
||||
dirAbs, ok := s.wsResolve(r.URL.Query().Get("path"))
|
||||
|
||||
Generated
-242
@@ -11,13 +11,6 @@
|
||||
"@antv/g6": "^5.1.1",
|
||||
"@antv/graphin": "^3.0.5",
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/language": "^6.13.1",
|
||||
"@codemirror/search": "^6.7.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.15",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
@@ -973,149 +966,6 @@
|
||||
"node": ">=14.21.3"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/autocomplete": {
|
||||
"version": "6.20.4",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/autocomplete/-/autocomplete-6.20.4.tgz",
|
||||
"integrity": "sha512-yF8RhOf2SXlg00+qtupzLhrglcas61boqaONiNcbPIebHCpJocpqTBA6dyrtUrQO47hqyTdeob/3XnYilT7TaQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/commands": {
|
||||
"version": "6.11.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/commands/-/commands-6.11.1.tgz",
|
||||
"integrity": "sha512-O/4hG3SC1YwcmQ0d2UVNDs+AsaNWd1iHVxbTeEBuqH+6bExAiPK3iS/BvpY6rZGURALv4ZD3sIgcCmRvw3ehBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"@codemirror/view": "^6.27.0",
|
||||
"@lezer/common": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-css": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-css/-/lang-css-6.3.1.tgz",
|
||||
"integrity": "sha512-kr5fwBGiGtmz6l0LSJIbno9QrifNMUusivHbnA1H6Dmqy4HZFte3UAICix1VuKo0lMPKQr2rqB+0BkKi/S3Ejg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@lezer/common": "^1.0.2",
|
||||
"@lezer/css": "^1.1.7"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-html": {
|
||||
"version": "6.4.12",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-html/-/lang-html-6.4.12.tgz",
|
||||
"integrity": "sha512-pw2ReWKUqSkbvh76RAT4NYxiogRu+PWkR2ukAwO9uOgrm8uipkzjtKKtNpyeAQwHOqxEeSvAXZ6vr3AfyB9y/w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/lang-css": "^6.0.0",
|
||||
"@codemirror/lang-javascript": "^6.0.0",
|
||||
"@codemirror/language": "^6.4.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/css": "^1.1.0",
|
||||
"@lezer/html": "^1.3.12"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-javascript": {
|
||||
"version": "6.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-javascript/-/lang-javascript-6.2.5.tgz",
|
||||
"integrity": "sha512-zD4e5mS+50htS7F+TYjBPsiIFGanfVqg4HyUz6WNFikgOPf2BgKlx+TQedI1w6n/IqRBVBbBWmGFdLB/7uxO4A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/autocomplete": "^6.0.0",
|
||||
"@codemirror/language": "^6.6.0",
|
||||
"@codemirror/lint": "^6.0.0",
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.17.0",
|
||||
"@lezer/common": "^1.0.0",
|
||||
"@lezer/javascript": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lang-json": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lang-json/-/lang-json-6.0.2.tgz",
|
||||
"integrity": "sha512-x2OtO+AvwEHrEwR0FyyPtfDUiloG3rnVTSZV1W8UteaLL8/MajQd8DpvUb2YVzC+/T18aSDv0H9mu+xw0EStoQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/language": "^6.0.0",
|
||||
"@lezer/json": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/language": {
|
||||
"version": "6.13.1",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/language/-/language-6.13.1.tgz",
|
||||
"integrity": "sha512-zfvU2r3TyylQLMj/te+5CnQ0rSqZi8B1oTCBbQkagcLW4Xc6imab6qN+pEJhsuNOLaJqiRyBXp9UhWA1DhVvDw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/streamparser": "^6.0.0",
|
||||
"@codemirror/view": "^6.23.0",
|
||||
"@lezer/common": "^1.5.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0",
|
||||
"style-mod": "^4.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/lint": {
|
||||
"version": "6.9.7",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/lint/-/lint-6.9.7.tgz",
|
||||
"integrity": "sha512-28/+iWLYxKxsvGYhSYL7zaCZqLz5+FFFDq9tVsvGv9kv8RY4fFAchJ5WX9M3YrrRlTIsECjsXPqeNgnSmNP2dg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.42.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/search": {
|
||||
"version": "6.7.2",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/search/-/search-6.7.2.tgz",
|
||||
"integrity": "sha512-gUYkYhT2+n/+VGZ+8EzE5WFkYZUZYm1VOKDudIsNqh42uRVQJ0a6Yss9sdKT3MeOYfuL1N6AZA57oza0Oyr0LA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.0.0",
|
||||
"@codemirror/view": "^6.37.0",
|
||||
"crelt": "^1.0.5"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/state": {
|
||||
"version": "6.7.6",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/state/-/state-6.7.6.tgz",
|
||||
"integrity": "sha512-kAz+AncRtKuIknedxT1bq4XwXv4UowhbkHU1myPrtVb/jZtImWuV5BXzv5vK6i3kYACsdiZiQKFQQ5Mq7elW8w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@marijn/find-cluster-break": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@codemirror/streamparser": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/streamparser/-/streamparser-6.0.0.tgz",
|
||||
"integrity": "sha512-JTFPF6xrfjt0mnfGPgzcLiMwYmvIyNaoizp8BYzv8FE8KrT6GBGDZ190rtt+O7Wke3gG/MFHyiY9YGNeimof4w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@codemirror/view": {
|
||||
"version": "6.43.15",
|
||||
"resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.43.15.tgz",
|
||||
"integrity": "sha512-HHsfpqFeHyXLs983pSRe38Xajs0vFWZQ2BuX0m7Ki8w3jZ5iD8cuR3lBz2/5/X53Dhf32SBHKh6rtSTB6tKeGw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@codemirror/state": "^6.7.0",
|
||||
"crelt": "^1.0.6",
|
||||
"style-mod": "^4.1.0",
|
||||
"w3c-keyname": "^2.2.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@cspotcode/source-map-support": {
|
||||
"version": "0.8.1",
|
||||
"resolved": "https://registry.npmjs.org/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz",
|
||||
@@ -2035,80 +1885,6 @@
|
||||
"@jridgewell/sourcemap-codec": "^1.4.14"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/common": {
|
||||
"version": "1.5.4",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/common/-/common-1.5.4.tgz",
|
||||
"integrity": "sha512-xt3Q4z64xM+xhg4M7NXqw1N//1O74bUFo5kz6fwMeC4qwxSuRdwkYSmjcqCEEKYkNw5e67f2hcNqBoNERAEU0w==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@lezer/css": {
|
||||
"version": "1.3.9",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/css/-/css-1.3.9.tgz",
|
||||
"integrity": "sha512-ZmzgvTOBdJWGIsY/ItAfN7UW8DV802oZG+jeG6E508SwEzz5YhuAajptpWPICvIyqYAD2+VjMdOS40GgGkyEOQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/highlight": {
|
||||
"version": "1.2.5",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/highlight/-/highlight-1.2.5.tgz",
|
||||
"integrity": "sha512-O1GMVKgtf5YspFaRzpmqkVgMtIk2HG9uMAbH6wdAIdNh3TVzCsch/+cRi+6l2//UGDKe6NB9BW3cvuR5ezMQ0w==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/html": {
|
||||
"version": "1.3.13",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/html/-/html-1.3.13.tgz",
|
||||
"integrity": "sha512-oI7n6NJml729m7pjm9lvLvmXbdoMoi2f+1pwSDJkl9d68zGr7a9Btz8NdHTGQZtW2DA25ybeuv/SyDb9D5tseg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/javascript": {
|
||||
"version": "1.5.6",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/javascript/-/javascript-1.5.6.tgz",
|
||||
"integrity": "sha512-sEvOqpl4vJQca0NugSQK02vQPbc5KrvoojnYGoqqFgFO0MtcYhwGiHKVEB+LuXYJnfyXo4QPA8ztbrEa4Fw5NA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.1.3",
|
||||
"@lezer/lr": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/json": {
|
||||
"version": "1.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/json/-/json-1.0.3.tgz",
|
||||
"integrity": "sha512-BP9KzdF9Y35PDpv04r0VeSTKDeox5vVr3efE7eBbx3r4s3oNLfunchejZhjArmeieBH+nVOpgIiBJpEAv8ilqQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.2.0",
|
||||
"@lezer/highlight": "^1.0.0",
|
||||
"@lezer/lr": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@lezer/lr": {
|
||||
"version": "1.4.11",
|
||||
"resolved": "https://registry.npmjs.org/@lezer/lr/-/lr-1.4.11.tgz",
|
||||
"integrity": "sha512-UwgZcl1R66H2hDzBrA7kWyYmX6s8p5TQ+e/t7CuvgCtBEPxbEPaHYHu+pJBvTvDotEJpuQkJjbXuHiw5Cixxgw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@lezer/common": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@marijn/find-cluster-break": {
|
||||
"version": "1.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@marijn/find-cluster-break/-/find-cluster-break-1.0.4.tgz",
|
||||
"integrity": "sha512-Wy0V7+SGUjnF9/TkiM1hKVDPj7jKXduPNboMVtHTA8dySMURWqfg/JZ9E2Sq8JgSJmkl7k7Qe9FLeMSrSraWmQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@modelcontextprotocol/sdk": {
|
||||
"version": "1.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@modelcontextprotocol/sdk/-/sdk-1.29.0.tgz",
|
||||
@@ -5396,12 +5172,6 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/crelt": {
|
||||
"version": "1.0.7",
|
||||
"resolved": "https://registry.npmjs.org/crelt/-/crelt-1.0.7.tgz",
|
||||
"integrity": "sha512-aK6BbWfhf4U/wCcLHKPJl/xa6VkVstRaPywWtMKGwuOLc/wZTyQYuoxgvZnNsBvv7Kg3YTBQYYBCggcviQczuA==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cross-spawn": {
|
||||
"version": "7.0.6",
|
||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||
@@ -10489,12 +10259,6 @@
|
||||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/style-mod": {
|
||||
"version": "4.1.4",
|
||||
"resolved": "https://registry.npmjs.org/style-mod/-/style-mod-4.1.4.tgz",
|
||||
"integrity": "sha512-XXWIQt633/EpAFx8aZDOTjBzrCaGmhvEQlQo6MVPfa2OzO2cWo+4hV9h+6UkHYlXGfy+ODXKUdP7Pthmcu5ATw==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/style-to-js": {
|
||||
"version": "1.1.21",
|
||||
"resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz",
|
||||
@@ -11164,12 +10928,6 @@
|
||||
"d3-timer": "^3.0.1"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/which": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/which/-/which-4.0.0.tgz",
|
||||
|
||||
@@ -23,13 +23,6 @@
|
||||
"@antv/g6": "^5.1.1",
|
||||
"@antv/graphin": "^3.0.5",
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@codemirror/commands": "^6.11.1",
|
||||
"@codemirror/lang-html": "^6.4.12",
|
||||
"@codemirror/lang-json": "^6.0.2",
|
||||
"@codemirror/language": "^6.13.1",
|
||||
"@codemirror/search": "^6.7.2",
|
||||
"@codemirror/state": "^6.7.6",
|
||||
"@codemirror/view": "^6.43.15",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@dnd-kit/modifiers": "^9.0.0",
|
||||
"@dnd-kit/sortable": "^10.0.0",
|
||||
|
||||
@@ -334,7 +334,7 @@ function LLMProfileRow({
|
||||
</span>
|
||||
)}
|
||||
</button>
|
||||
{profiles.filter((p) => p.format !== "jev").map((p) => (
|
||||
{profiles.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
|
||||
@@ -9,9 +9,6 @@ import {
|
||||
ArrowUpRightIcon,
|
||||
BugIcon,
|
||||
ClockIcon,
|
||||
CpuIcon,
|
||||
HardDriveIcon,
|
||||
MemoryStickIcon,
|
||||
NetworkIcon,
|
||||
ShieldCheckIcon,
|
||||
TargetIcon,
|
||||
@@ -41,7 +38,6 @@ import type {
|
||||
Tool,
|
||||
TrafficExchange,
|
||||
UsageStats,
|
||||
SystemMetrics,
|
||||
} from "@/lib/types";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@@ -105,35 +101,6 @@ function statusBg(code: number): string {
|
||||
return "bg-red-400";
|
||||
}
|
||||
|
||||
function SystemResourceStatus({ metrics }: { metrics: SystemMetrics | null }) {
|
||||
if (!metrics) return null;
|
||||
if (!metrics.supported) {
|
||||
return <div className="ml-auto text-xs text-muted-foreground" role="status">资源状态:暂不支持</div>;
|
||||
}
|
||||
const items = [
|
||||
[CpuIcon, "CPU", metrics.cpu.percent],
|
||||
[MemoryStickIcon, "内存", metrics.memory.percent],
|
||||
[HardDriveIcon, "磁盘", metrics.data_disk.percent],
|
||||
] as const;
|
||||
return (
|
||||
<div className="ml-auto flex min-w-0 items-center justify-end gap-3 text-xs text-muted-foreground" role="status" aria-live="polite">
|
||||
<span className="shrink-0 font-medium">容器资源</span>
|
||||
{items.map(([Icon, label, value]) => (
|
||||
<div key={label} className="w-24 min-w-0" title={`${label} ${value.toFixed(1)}%`}>
|
||||
<div className="mb-1 flex items-center justify-between gap-1 text-[10px]">
|
||||
<span className="inline-flex items-center gap-1"><Icon className="size-3" aria-hidden="true" />{label}</span>
|
||||
<span className="tabular-nums">{value.toFixed(1)}%</span>
|
||||
</div>
|
||||
<Progress
|
||||
value={value}
|
||||
className="h-2 bg-muted [&_[data-slot=progress-indicator]]:bg-slate-500 dark:[&_[data-slot=progress-indicator]]:bg-slate-400"
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── sub-components ────────────────────────────────────────────────────────────
|
||||
|
||||
function LiveDot({ className }: { className?: string }) {
|
||||
@@ -180,22 +147,6 @@ export default function DashboardPage() {
|
||||
const [skills, setSkills] = React.useState<SkillItem[]>([]);
|
||||
const [tools, setTools] = React.useState<Tool[]>([]);
|
||||
const [llmProfiles, setLLMProfiles] = React.useState<LLMProfile[]>([]);
|
||||
const [systemMetrics, setSystemMetrics] = React.useState<SystemMetrics | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
let alive = true;
|
||||
const load = async () => {
|
||||
try {
|
||||
const next = await api.systemMetrics();
|
||||
if (alive) setSystemMetrics(next);
|
||||
} catch {
|
||||
/* retry on the next interval */
|
||||
}
|
||||
};
|
||||
void load();
|
||||
const timer = setInterval(load, 5000);
|
||||
return () => { alive = false; clearInterval(timer); };
|
||||
}, []);
|
||||
|
||||
// The task list is the most expensive dashboard source. Poll it independently
|
||||
// so a large history cannot hold back every other dashboard panel.
|
||||
@@ -544,13 +495,10 @@ export default function DashboardPage() {
|
||||
<div className="flex flex-1 flex-col gap-4 pb-6">
|
||||
{/* ── Header ── */}
|
||||
<div>
|
||||
<div className="flex items-center gap-6">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight">总览</h1>
|
||||
<p className="text-xs text-muted-foreground">系统全局状态 · 实时刷新</p>
|
||||
</div>
|
||||
<SystemResourceStatus metrics={systemMetrics} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* ── Row 1: 5 stat cards ── */}
|
||||
|
||||
@@ -72,7 +72,6 @@ function FindingDetailInner() {
|
||||
const [tab, setTab] = React.useState("overview");
|
||||
const [reportGenerating, setReportGenerating] = React.useState(false);
|
||||
const [autoChatOpen, setAutoChatOpen] = React.useState(false);
|
||||
const [reportChatOpen, setReportChatOpen] = React.useState(false);
|
||||
const title = finding?.name || finding?.vulnclass || "未分类";
|
||||
|
||||
const load = React.useCallback(() => {
|
||||
@@ -191,12 +190,6 @@ function FindingDetailInner() {
|
||||
<BotIcon data-icon="inline-start" />
|
||||
询问 Auto Agent
|
||||
</Button>
|
||||
{!finding.inherited ? (
|
||||
<Button variant="outline" size="sm" onClick={() => setReportChatOpen(true)} title="打开当前漏洞报告撰写对话">
|
||||
<FileTextIcon data-icon="inline-start" />
|
||||
调整报告
|
||||
</Button>
|
||||
) : null}
|
||||
{finding.inherited && finding.source_task_id && (
|
||||
<Badge variant="outline">来源任务 #{finding.source_task_id} · 只读</Badge>
|
||||
)}
|
||||
@@ -417,17 +410,6 @@ function FindingDetailInner() {
|
||||
open={autoChatOpen}
|
||||
onOpenChange={setAutoChatOpen}
|
||||
/>
|
||||
<FindingAutoChat
|
||||
findingId={finding.id}
|
||||
findingTitle={title}
|
||||
agentKey="reporter"
|
||||
agentLabel="报告撰写 Agent"
|
||||
promptPlaceholder="描述要如何调整漏洞报告…"
|
||||
modal
|
||||
initialConversationId={finding.report_conversation_id}
|
||||
open={reportChatOpen}
|
||||
onOpenChange={setReportChatOpen}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -923,7 +923,7 @@ export default function FindingsPage() {
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="scrollbar-none flex min-w-0 flex-nowrap items-center gap-2 overflow-x-auto pb-1">
|
||||
<div className="flex min-w-0 flex-nowrap items-center gap-2 overflow-x-auto pb-1">
|
||||
<InputGroup className="w-72 shrink-0">
|
||||
<InputGroupInput
|
||||
type="search"
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
@@ -207,7 +207,6 @@ const defaultJudge = (): JudgeConfig => ({
|
||||
ask_timeout_seconds: 300,
|
||||
ask_timeout_action: "deny",
|
||||
incomplete_script_action: "model",
|
||||
confidence_threshold: 0.55,
|
||||
});
|
||||
|
||||
function JudgeCard() {
|
||||
@@ -345,7 +344,7 @@ function JudgeCard() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<p className="text-sm font-medium">审批提示词</p>
|
||||
<p className="text-xs text-muted-foreground">模型据此判定 ALLOW / ASK / DENY,可直接编辑;选 Jev 时会作为 System One 的 policy/instructions</p>
|
||||
<p className="text-xs text-muted-foreground">模型据此判定 ALLOW / ASK / DENY,可直接编辑</p>
|
||||
</div>
|
||||
<Button variant="ghost" size="sm" className="h-7 text-xs" onClick={restorePrompt} disabled={saving}>
|
||||
恢复默认模板
|
||||
@@ -374,26 +373,12 @@ function JudgeCard() {
|
||||
<SelectItem value={FOLLOW_ACTIVE}>跟随激活配置</SelectItem>
|
||||
{profiles.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>
|
||||
{p.name}({p.model}){p.format === "jev" ? " · Jev / System One" : ""}
|
||||
{p.name}({p.model})
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Jev 低置信度阈值">
|
||||
<Input
|
||||
type="number"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.01}
|
||||
value={cfg.confidence_threshold ?? 0.55}
|
||||
onChange={(e) => {
|
||||
const n = Number(e.target.value);
|
||||
if (Number.isFinite(n) && n >= 0 && n <= 1) patch({ confidence_threshold: n });
|
||||
}}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">仅 Jev / System One 生效;置信度低于此值自动转人工,默认 0.55。</p>
|
||||
</Field>
|
||||
<Field label="每次模型判定超时(秒)">
|
||||
<Input
|
||||
type="number"
|
||||
|
||||
@@ -311,7 +311,7 @@ function ProfileSheet({
|
||||
}) {
|
||||
const isNew = !profile;
|
||||
const [name, setName] = React.useState("");
|
||||
const [format, setFormat] = React.useState<"anthropic" | "openai" | "openai-responses" | "jev">("anthropic");
|
||||
const [format, setFormat] = React.useState<"anthropic" | "openai" | "openai-responses">("anthropic");
|
||||
const [model, setModel] = React.useState("");
|
||||
const [baseUrl, setBaseUrl] = React.useState("");
|
||||
const [proxy, setProxy] = React.useState("");
|
||||
@@ -340,7 +340,7 @@ function ProfileSheet({
|
||||
React.useEffect(() => {
|
||||
if (!open) return;
|
||||
setName(profile?.name ?? "");
|
||||
setFormat(profile?.format === "openai" || profile?.format === "openai-responses" || profile?.format === "jev" ? profile.format : "anthropic");
|
||||
setFormat(profile?.format === "openai" || profile?.format === "openai-responses" ? profile.format : "anthropic");
|
||||
setModel(profile?.model ?? "");
|
||||
setBaseUrl(profile?.base_url ?? "");
|
||||
setProxy(profile?.proxy ?? "");
|
||||
@@ -492,7 +492,7 @@ function ProfileSheet({
|
||||
</div>
|
||||
<div className="grid gap-2">
|
||||
<Label>格式</Label>
|
||||
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai" | "openai-responses" | "jev")}>
|
||||
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai" | "openai-responses")}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="选择格式" />
|
||||
</SelectTrigger>
|
||||
@@ -500,7 +500,6 @@ function ProfileSheet({
|
||||
<SelectItem value="anthropic">Anthropic</SelectItem>
|
||||
<SelectItem value="openai">OpenAI (Chat Completions)</SelectItem>
|
||||
<SelectItem value="openai-responses">OpenAI (Responses API)</SelectItem>
|
||||
<SelectItem value="jev">Jev / System One(仅拦截判定)</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
@@ -917,7 +916,7 @@ export default function LLMPage() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="truncate font-medium text-sm">{p.name}</span>
|
||||
<Badge variant="outline" className="uppercase">
|
||||
{p.format === "jev" ? "Jev / System One" : p.format}
|
||||
{p.format}
|
||||
</Badge>
|
||||
<Badge variant="outline" className={cn("ml-auto", h.cls)} title={h.hint}>
|
||||
{h.label}
|
||||
@@ -947,13 +946,13 @@ export default function LLMPage() {
|
||||
size="sm"
|
||||
variant="outline"
|
||||
className="flex-1"
|
||||
disabled={p.is_default || p.format === "jev"}
|
||||
disabled={p.is_default}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
void activate(p.id, p.name);
|
||||
}}
|
||||
>
|
||||
{p.is_default ? "已激活" : p.format === "jev" ? "判定专用" : "设为激活"}
|
||||
{p.is_default ? "已激活" : "设为激活"}
|
||||
</Button>
|
||||
<Button
|
||||
size="icon"
|
||||
|
||||
@@ -1324,7 +1324,7 @@ export default function SystemSettingsPage() {
|
||||
{!selected && pool.profile_id ? (
|
||||
<SelectItem value={pool.profile_id}>配置 #{pool.profile_id} 已删除/不可用</SelectItem>
|
||||
) : null}
|
||||
{llmProfiles.filter((profile) => profile.format !== "jev").map((profile) => (
|
||||
{llmProfiles.map((profile) => (
|
||||
<SelectItem key={profile.id} value={profile.id}>
|
||||
{profile.name} · {profile.model}
|
||||
</SelectItem>
|
||||
|
||||
@@ -65,28 +65,6 @@ function originLabel(row: InterceptApprovalRow) {
|
||||
return "—";
|
||||
}
|
||||
|
||||
// Background task runs carry a pool label such as "work#2 · #2770", while
|
||||
// conversation-backed runs carry the agent key (for example "reporter").
|
||||
// Present a stable, human-readable agent label in both cases.
|
||||
function agentLabel(row: InterceptApprovalRow) {
|
||||
const key = (row.conv_agent_key || "").trim().toLowerCase();
|
||||
const labels: Record<string, string> = {
|
||||
auto: "自动 Agent",
|
||||
mainagent: "主 Agent",
|
||||
planner: "规划 Agent",
|
||||
worker: "执行 Worker",
|
||||
reporter: "报告编写",
|
||||
retester: "复测 Agent",
|
||||
pentest: "渗透测试 Agent",
|
||||
};
|
||||
if (key && labels[key]) return labels[key];
|
||||
const raw = (row.agent_name || "").trim();
|
||||
if (/^work#\d+(?:\s|$)/i.test(raw)) return "执行 Worker";
|
||||
const rawKey = raw.toLowerCase();
|
||||
if (labels[rawKey]) return labels[rawKey];
|
||||
return raw || "未知 Agent";
|
||||
}
|
||||
|
||||
function ApprovalOrigin({ row, detail = false }: { row: InterceptApprovalRow; detail?: boolean }) {
|
||||
const [locating, setLocating] = React.useState(false);
|
||||
const label = detail && row.task_id ? `任务 ${row.task_id}` : originLabel(row);
|
||||
@@ -476,7 +454,7 @@ export function ApprovalDetail({
|
||||
<SideQuestionButton side={side} />
|
||||
</div>
|
||||
<CodeBlock
|
||||
label={`${agentLabel(current)} · 工具请求`}
|
||||
label={`${current.agent_name || current.conv_agent_key || "Agent"} · 工具请求`}
|
||||
text={JSON.stringify(row.tool_input ?? {}, null, 2)}
|
||||
/>
|
||||
{command ? (
|
||||
@@ -748,8 +726,8 @@ function ApprovalTable({
|
||||
<span className="truncate" title={originLabel(row)}>
|
||||
<ApprovalOrigin row={row} />
|
||||
</span>
|
||||
<span className="truncate text-muted-foreground text-xs" title={agentLabel(row)}>
|
||||
Agent:{agentLabel(row)}
|
||||
<span className="truncate text-muted-foreground text-xs">
|
||||
{row.agent_name || row.conv_agent_key}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
|
||||
@@ -22,21 +22,11 @@ function mergeActivities(oldItems: Activity[], incoming: Activity[]) {
|
||||
export function FindingAutoChat({
|
||||
findingId,
|
||||
findingTitle,
|
||||
agentKey = "auto",
|
||||
agentLabel = "Auto Agent",
|
||||
promptPlaceholder = "询问这个漏洞…",
|
||||
modal = false,
|
||||
initialConversationId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: {
|
||||
findingId: string;
|
||||
findingTitle: string;
|
||||
agentKey?: string;
|
||||
agentLabel?: string;
|
||||
promptPlaceholder?: string;
|
||||
modal?: boolean;
|
||||
initialConversationId?: number;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}) {
|
||||
@@ -51,9 +41,8 @@ export function FindingAutoChat({
|
||||
const [historyOpen, setHistoryOpen] = React.useState(false);
|
||||
const [history, setHistory] = React.useState<Conversation[]>([]);
|
||||
const cursor = React.useRef(0);
|
||||
const chatViewportRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const safeTitle = findingTitle.replace(/[\[\]\r\n]/g, " ").trim().slice(0, 100);
|
||||
const storageKey = `artex.finding.${agentKey}-conversation.${findingId}`;
|
||||
const storageKey = `artex.finding.auto-conversation.${findingId}`;
|
||||
|
||||
const createConversation = React.useCallback(async () => {
|
||||
setManualEmpty(false);
|
||||
@@ -61,7 +50,7 @@ export function FindingAutoChat({
|
||||
setRunning(false);
|
||||
setLoading(true);
|
||||
try {
|
||||
const created = await api.createConversation(agentKey, `漏洞 #${findingId} · ${safeTitle}`);
|
||||
const created = await api.createConversation("auto", `漏洞 #${findingId} · ${safeTitle}`);
|
||||
window.localStorage.setItem(storageKey, String(created.id));
|
||||
setConversation(created);
|
||||
} catch (error) {
|
||||
@@ -69,19 +58,15 @@ export function FindingAutoChat({
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [agentKey, findingId, safeTitle, storageKey]);
|
||||
}, [findingId, safeTitle, storageKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || conversation || manualEmpty) return;
|
||||
if (initialConversationId && initialConversationId > 0) {
|
||||
setConversation({ id: initialConversationId, agent_key: agentKey, title: `漏洞 #${findingId} · ${safeTitle}`, created_at: "", updated_at: "" });
|
||||
return;
|
||||
}
|
||||
const storedID = agentKey === "auto" ? Number(window.localStorage.getItem(storageKey)) : 0;
|
||||
const storedID = Number(window.localStorage.getItem(storageKey));
|
||||
if (Number.isSafeInteger(storedID) && storedID > 0) {
|
||||
setConversation({
|
||||
id: storedID,
|
||||
agent_key: agentKey,
|
||||
agent_key: "auto",
|
||||
title: `漏洞 #${findingId} · ${safeTitle}`,
|
||||
created_at: "",
|
||||
updated_at: "",
|
||||
@@ -89,7 +74,7 @@ export function FindingAutoChat({
|
||||
return;
|
||||
}
|
||||
void createConversation();
|
||||
}, [conversation, createConversation, initialConversationId, manualEmpty, open, safeTitle, storageKey]);
|
||||
}, [conversation, createConversation, manualEmpty, open, safeTitle, storageKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!historyOpen && !running) return;
|
||||
@@ -100,7 +85,7 @@ export function FindingAutoChat({
|
||||
const items = await api.conversations();
|
||||
if (!live) return;
|
||||
const prefix = `漏洞 #${findingId} ·`;
|
||||
setHistory(items.filter((item) => item.agent_key === agentKey && item.title.startsWith(prefix)));
|
||||
setHistory(items.filter((item) => item.agent_key === "auto" && item.title.startsWith(prefix)));
|
||||
} catch {
|
||||
if (live && historyOpen) setHistory([]);
|
||||
} finally {
|
||||
@@ -112,7 +97,7 @@ export function FindingAutoChat({
|
||||
live = false;
|
||||
clearTimeout(timer);
|
||||
};
|
||||
}, [agentKey, findingId, historyOpen, running]);
|
||||
}, [findingId, historyOpen, running]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !conversation) return;
|
||||
@@ -137,18 +122,6 @@ export function FindingAutoChat({
|
||||
};
|
||||
}, [conversation, open, storageKey]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !conversation || loading || !messages.length) return;
|
||||
const viewport = chatViewportRef.current;
|
||||
if (!viewport) return;
|
||||
const key = `artex.conversation-scroll.${conversation.id}`;
|
||||
const saved = window.sessionStorage.getItem(key);
|
||||
requestAnimationFrame(() => {
|
||||
if (saved != null) viewport.scrollTop = Number(saved) || 0;
|
||||
else viewport.scrollTop = viewport.scrollHeight;
|
||||
});
|
||||
}, [conversation, loading, messages.length, open]);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open || !conversation || !running) return;
|
||||
let live = true;
|
||||
@@ -230,17 +203,13 @@ export function FindingAutoChat({
|
||||
if (!open) return null;
|
||||
|
||||
return (
|
||||
<div className={modal
|
||||
? "pointer-events-none fixed inset-0 z-50 flex items-center justify-center bg-black/35 p-4"
|
||||
: "pointer-events-none fixed inset-x-0 top-20 z-40 flex justify-end px-4 pb-4 sm:px-6"}>
|
||||
<section className={modal
|
||||
? "pointer-events-auto flex min-w-0 h-[min(760px,calc(100svh-2rem))] w-[min(760px,calc(100vw-2rem))] flex-col overflow-hidden rounded-xl border bg-background shadow-2xl"
|
||||
: "pointer-events-auto flex min-w-0 h-[calc(100svh-6rem)] w-[min(520px,calc(100vw-2rem))] flex-col overflow-hidden rounded-xl border bg-background shadow-2xl"}>
|
||||
<div className="pointer-events-none fixed inset-x-0 top-20 z-40 flex justify-end px-4 pb-4 sm:px-6">
|
||||
<section className="pointer-events-auto flex min-w-0 h-[calc(100svh-6rem)] w-[min(520px,calc(100vw-2rem))] flex-col overflow-hidden rounded-xl border bg-background shadow-2xl">
|
||||
<header className="flex items-center gap-2 border-b p-3">
|
||||
<BotIcon className="size-4 text-primary" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex items-center gap-2 font-medium">
|
||||
{agentLabel}
|
||||
Auto Agent
|
||||
{running && <span className="inline-flex items-center gap-1 rounded bg-blue-500/15 px-1.5 py-0.5 text-blue-600 text-xs"><span className="size-1.5 animate-pulse rounded-full bg-blue-500" />运行中</span>}
|
||||
</p>
|
||||
<p className="truncate text-muted-foreground text-xs">漏洞 #{findingId} · {safeTitle}</p>
|
||||
@@ -284,13 +253,7 @@ export function FindingAutoChat({
|
||||
<XIcon />
|
||||
</Button>
|
||||
</header>
|
||||
<div
|
||||
ref={chatViewportRef}
|
||||
onScroll={(event) => {
|
||||
if (conversation) window.sessionStorage.setItem(`artex.conversation-scroll.${conversation.id}`, String(event.currentTarget.scrollTop));
|
||||
}}
|
||||
className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto p-3"
|
||||
>
|
||||
<div className="min-h-0 min-w-0 flex-1 overflow-x-hidden overflow-y-auto p-3">
|
||||
<div className="w-full min-w-0 max-w-full">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-10 text-muted-foreground text-sm">
|
||||
@@ -311,7 +274,7 @@ export function FindingAutoChat({
|
||||
inputGroup
|
||||
onValueChange={setInput}
|
||||
disabled={!conversation || loading || sending || running}
|
||||
placeholder={promptPlaceholder}
|
||||
placeholder="询问这个漏洞…"
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Enter" && !event.shiftKey && !event.nativeEvent.isComposing) {
|
||||
event.preventDefault();
|
||||
|
||||
@@ -7,7 +7,6 @@ import { toast } from "sonner";
|
||||
import { Button } from "@/components/ui/button";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip";
|
||||
import { cn, copyText } from "@/lib/utils";
|
||||
import { PacketCodeEditor } from "@/components/packet-code-editor";
|
||||
|
||||
function statusTone(status: number) {
|
||||
if (status >= 500) return "text-red-500";
|
||||
@@ -309,8 +308,34 @@ export function HttpCodeBlock({ raw }: { raw: string }) {
|
||||
<TooltipContent side="bottom">{copied ? "已复制" : "复制报文"}</TooltipContent>
|
||||
</Tooltip>
|
||||
</div>
|
||||
<div className="min-h-0 max-h-[calc(100vh-15rem)] flex-1 min-w-0 overflow-hidden bg-background">
|
||||
<PacketCodeEditor value={value} readOnly wrapLines={wrapLines} />
|
||||
{/* biome-ignore lint/a11y/useSemanticElements: textarea cannot preserve line numbers and syntax-highlighting markup. */}
|
||||
<div
|
||||
role="textbox"
|
||||
aria-label="HTTP 报文代码"
|
||||
aria-multiline="true"
|
||||
aria-readonly="true"
|
||||
tabIndex={0}
|
||||
className="min-h-0 max-h-[calc(100vh-15rem)] flex-1 min-w-0 overflow-auto bg-background py-2 font-mono text-xs outline-none focus-visible:ring-2 focus-visible:ring-inset focus-visible:ring-ring"
|
||||
onCopy={(event) => {
|
||||
const text = selectedPacketText(event.currentTarget, value, lines);
|
||||
if (text === null) return;
|
||||
event.preventDefault();
|
||||
event.clipboardData.setData("text/plain", text);
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (!(event.ctrlKey || event.metaKey) || event.key.toLowerCase() !== "a") return;
|
||||
event.preventDefault();
|
||||
const selection = window.getSelection();
|
||||
if (!selection) return;
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(event.currentTarget);
|
||||
selection.removeAllRanges();
|
||||
selection.addRange(range);
|
||||
}}
|
||||
>
|
||||
<PacketHighlightContext.Provider value={query}>
|
||||
<HttpPacketSyntax raw={value} wrapLines={wrapLines} />
|
||||
</PacketHighlightContext.Provider>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border-t bg-muted/20 px-2 py-1">
|
||||
<SearchIcon className="size-3.5 shrink-0 text-muted-foreground" />
|
||||
|
||||
@@ -18,8 +18,6 @@ function AuthenticatedImage({
|
||||
const [captureTimestamp, setCaptureTimestamp] = React.useState<string | null>(null);
|
||||
const [zoomed, setZoomed] = React.useState(false);
|
||||
const [zoomScale, setZoomScale] = React.useState(1);
|
||||
const [pan, setPan] = React.useState({ x: 0, y: 0 });
|
||||
const dragRef = React.useRef<{ x: number; y: number; panX: number; panY: number } | null>(null);
|
||||
const imageSrc = typeof src === "string" ? src : undefined;
|
||||
|
||||
React.useEffect(() => {
|
||||
@@ -31,7 +29,6 @@ function AuthenticatedImage({
|
||||
setCaptureTimestamp(null);
|
||||
setZoomed(false);
|
||||
setZoomScale(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
@@ -78,55 +75,31 @@ function AuthenticatedImage({
|
||||
onClick={() => renderedSrc && setZoomed(true)}
|
||||
aria-label="放大查看报告截图"
|
||||
>
|
||||
<img {...props} src={renderedSrc} alt={alt || "报告截图,点击放大"} draggable={false} onDragStart={(event) => event.preventDefault()} />
|
||||
<img {...props} src={renderedSrc} alt={alt || "报告截图,点击放大"} />
|
||||
{timestamp}
|
||||
</button>
|
||||
<Dialog
|
||||
open={zoomed}
|
||||
onOpenChange={(next) => {
|
||||
setZoomed(next);
|
||||
if (!next) {
|
||||
setZoomScale(1);
|
||||
setPan({ x: 0, y: 0 });
|
||||
}
|
||||
if (!next) setZoomScale(1);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
showCloseButton
|
||||
className="flex max-h-[96dvh] max-w-[96vw] items-center justify-center overflow-hidden bg-black/85 p-3 backdrop-blur-sm sm:max-w-[96vw]"
|
||||
>
|
||||
<div
|
||||
className="relative flex h-[90dvh] w-[90vw] max-w-full items-center justify-center overflow-hidden"
|
||||
onPointerDown={(event) => {
|
||||
if (zoomScale <= 1 || event.button !== 0) return;
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
dragRef.current = { x: event.clientX, y: event.clientY, panX: pan.x, panY: pan.y };
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag) return;
|
||||
setPan({ x: drag.panX + event.clientX - drag.x, y: drag.panY + event.clientY - drag.y });
|
||||
}}
|
||||
onPointerUp={(event) => {
|
||||
dragRef.current = null;
|
||||
event.currentTarget.releasePointerCapture?.(event.pointerId);
|
||||
}}
|
||||
onPointerCancel={() => { dragRef.current = null; }}
|
||||
style={{ cursor: zoomScale > 1 ? (dragRef.current ? "grabbing" : "grab") : "default" }}
|
||||
>
|
||||
<div className="relative inline-block origin-center" style={{ transform: `translate(${pan.x}px, ${pan.y}px) scale(${zoomScale})` }}>
|
||||
<div className="relative flex max-h-[90dvh] max-w-full items-center justify-center overflow-auto">
|
||||
<div className="relative inline-block origin-center transition-transform duration-150" style={{ transform: `scale(${zoomScale})` }}>
|
||||
<img
|
||||
src={renderedSrc}
|
||||
alt={alt || "报告截图"}
|
||||
draggable={false}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
className="max-h-[90dvh] max-w-[90vw] rounded-md object-contain"
|
||||
/>
|
||||
{timestamp}
|
||||
</div>
|
||||
</div>
|
||||
<div className="absolute top-3 left-3 z-10 flex items-center gap-1 rounded-lg bg-black/60 p-1 backdrop-blur-sm">
|
||||
<span className="px-2 text-xs font-medium text-white tabular-nums">{Math.round(zoomScale * 100)}%</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon-sm"
|
||||
|
||||
@@ -1,123 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import * as React from "react";
|
||||
import { EditorState } from "@codemirror/state";
|
||||
import { drawSelection, EditorView, highlightActiveLine, keymap, lineNumbers } from "@codemirror/view";
|
||||
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
|
||||
import { defaultHighlightStyle, HighlightStyle, StreamLanguage, syntaxHighlighting } from "@codemirror/language";
|
||||
import { tags } from "@lezer/highlight";
|
||||
|
||||
const packetHighlight = HighlightStyle.define([
|
||||
{ tag: tags.keyword, color: "#d95656", fontWeight: "700" },
|
||||
{ tag: tags.string, color: "#0284c7" },
|
||||
{ tag: tags.variableName, color: "#286dcc", fontWeight: "650" },
|
||||
{ tag: tags.typeName, color: "#6b7280" },
|
||||
{ tag: tags.number, color: "#c026d3" },
|
||||
{ tag: tags.bool, color: "#e11d48" },
|
||||
]);
|
||||
|
||||
const httpLanguage = StreamLanguage.define({
|
||||
startState: () => ({ line: 0 }),
|
||||
token(stream, state) {
|
||||
if (stream.sol()) state.line += 1;
|
||||
if (stream.match(/^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS|CONNECT|TRACE)\b/)) return "keyword";
|
||||
if (stream.match(/^HTTP\/\d(?:\.\d)?\b/)) return "typeName";
|
||||
if (stream.match(/^[A-Za-z][A-Za-z0-9-]*(?=:)/)) return "variableName";
|
||||
if (stream.match(/^https?:\/\/\S+/)) return "string";
|
||||
if (stream.match(/^(?:\d{3})(?:\s+[^\n]*)?/)) return "number";
|
||||
if (stream.match(/^\b(?:true|false|null)\b/)) return "bool";
|
||||
stream.next();
|
||||
return null;
|
||||
},
|
||||
});
|
||||
|
||||
const packetTheme = EditorView.theme(
|
||||
{
|
||||
"&": { height: "100%", backgroundColor: "transparent", color: "var(--foreground)" },
|
||||
".cm-scroller": {
|
||||
overflow: "auto",
|
||||
fontFamily: "var(--font-mono)",
|
||||
fontSize: "0.75rem",
|
||||
fontWeight: "500",
|
||||
lineHeight: "1.65rem",
|
||||
},
|
||||
".cm-content": { minHeight: "100%", padding: "0.5rem 0.5rem" },
|
||||
".cm-gutters": {
|
||||
backgroundColor: "transparent",
|
||||
color: "var(--muted-foreground)",
|
||||
border: "0",
|
||||
borderRight: "1px solid var(--border)",
|
||||
},
|
||||
".cm-lineNumbers .cm-gutterElement": { minWidth: "2rem", padding: "0 0.5rem" },
|
||||
".cm-activeLine": { backgroundColor: "transparent" },
|
||||
".cm-activeLineGutter": { backgroundColor: "transparent" },
|
||||
".cm-selectionBackground, ::selection": { backgroundColor: "#c9d2e3" },
|
||||
".cm-cursor": { borderLeftColor: "#222" },
|
||||
},
|
||||
{ dark: false },
|
||||
);
|
||||
|
||||
export function PacketCodeEditor({
|
||||
value,
|
||||
onChange,
|
||||
readOnly = false,
|
||||
wrapLines = true,
|
||||
ariaLabel = "HTTP 报文代码",
|
||||
}: {
|
||||
value: string;
|
||||
onChange?: (value: string) => void;
|
||||
readOnly?: boolean;
|
||||
wrapLines?: boolean;
|
||||
ariaLabel?: string;
|
||||
}) {
|
||||
const host = React.useRef<HTMLDivElement>(null);
|
||||
const viewRef = React.useRef<EditorView | null>(null);
|
||||
const valueRef = React.useRef(value);
|
||||
const onChangeRef = React.useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!host.current) return;
|
||||
const state = EditorState.create({
|
||||
doc: value,
|
||||
extensions: [
|
||||
lineNumbers(),
|
||||
drawSelection(),
|
||||
highlightActiveLine(),
|
||||
history(),
|
||||
keymap.of([...defaultKeymap, ...historyKeymap, indentWithTab]),
|
||||
httpLanguage,
|
||||
syntaxHighlighting(defaultHighlightStyle),
|
||||
syntaxHighlighting(packetHighlight),
|
||||
packetTheme,
|
||||
...(wrapLines ? [EditorView.lineWrapping] : []),
|
||||
EditorView.editable.of(!readOnly),
|
||||
EditorState.readOnly.of(readOnly),
|
||||
EditorView.contentAttributes.of({ role: "textbox", "aria-label": ariaLabel, "aria-multiline": "true" }),
|
||||
EditorView.updateListener.of((update) => {
|
||||
if (!update.docChanged) return;
|
||||
const next = update.state.doc.toString();
|
||||
valueRef.current = next;
|
||||
onChangeRef.current?.(next);
|
||||
}),
|
||||
],
|
||||
});
|
||||
const view = new EditorView({ state, parent: host.current });
|
||||
viewRef.current = view;
|
||||
return () => {
|
||||
viewRef.current = null;
|
||||
view.destroy();
|
||||
};
|
||||
}, [ariaLabel, readOnly, wrapLines]);
|
||||
|
||||
React.useEffect(() => {
|
||||
const view = viewRef.current;
|
||||
if (!view || valueRef.current === value) return;
|
||||
const current = view.state.doc.toString();
|
||||
if (current === value) return;
|
||||
view.dispatch({ changes: { from: 0, to: current.length, insert: value } });
|
||||
valueRef.current = value;
|
||||
}, [value]);
|
||||
|
||||
return <div ref={host} className="h-full min-h-0 w-full" />;
|
||||
}
|
||||
@@ -6,7 +6,8 @@ import { WrapTextIcon } from "lucide-react";
|
||||
|
||||
import { LiveClock } from "@/app/(main)/_components/live-clock";
|
||||
import { CopyButton } from "@/components/copy-button";
|
||||
import { PacketCodeEditor } from "@/components/packet-code-editor";
|
||||
import { HttpPacketSyntax } from "@/components/http-code-block";
|
||||
import { Textarea } from "@/components/ui/textarea";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
export function RepeaterTextPane({
|
||||
@@ -15,6 +16,8 @@ export function RepeaterTextPane({
|
||||
value,
|
||||
onChange,
|
||||
disabled,
|
||||
placeholder,
|
||||
syntaxHighlight = false,
|
||||
toolbar,
|
||||
panelClassName,
|
||||
showClock = false,
|
||||
@@ -30,8 +33,11 @@ export function RepeaterTextPane({
|
||||
panelClassName?: string;
|
||||
showClock?: boolean;
|
||||
}) {
|
||||
const gutter = React.useRef<HTMLPreElement>(null);
|
||||
const syntax = React.useRef<HTMLDivElement>(null);
|
||||
const [wrapLines, setWrapLines] = React.useState(true);
|
||||
const lines = React.useMemo(() => value.split(/\r\n|\r|\n/).length, [value]);
|
||||
const numbers = React.useMemo(() => Array.from({ length: lines }, (_, index) => index + 1).join("\n"), [lines]);
|
||||
return (
|
||||
<section
|
||||
className={cn(
|
||||
@@ -59,13 +65,54 @@ export function RepeaterTextPane({
|
||||
<CopyButton text={value} label={`复制${label}`} className={showClock ? "ml-0" : "ml-auto"} />
|
||||
</div>
|
||||
<div className="group relative flex min-h-0 flex-1 overflow-hidden rounded border border-input focus-within:border-ring">
|
||||
<PacketCodeEditor
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className="w-12 shrink-0 overflow-hidden border-r bg-muted/30 text-right text-muted-foreground"
|
||||
>
|
||||
<pre ref={gutter} className="select-none px-2 py-2 font-mono text-xs leading-5">
|
||||
{numbers}
|
||||
</pre>
|
||||
</div>
|
||||
<Textarea
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
readOnly={!onChange || disabled}
|
||||
wrapLines={wrapLines}
|
||||
ariaLabel={label}
|
||||
onChange={(event) => onChange?.(event.target.value)}
|
||||
onScroll={(event) => {
|
||||
if (gutter.current) gutter.current.style.transform = `translateY(-${event.currentTarget.scrollTop}px)`;
|
||||
if (syntax.current) {
|
||||
syntax.current.scrollTop = event.currentTarget.scrollTop;
|
||||
syntax.current.scrollLeft = event.currentTarget.scrollLeft;
|
||||
}
|
||||
}}
|
||||
disabled={disabled}
|
||||
readOnly={!onChange}
|
||||
placeholder={placeholder}
|
||||
wrap={wrapLines ? "soft" : "off"}
|
||||
spellCheck={false}
|
||||
autoCapitalize="off"
|
||||
autoCorrect="off"
|
||||
className={
|
||||
"field-sizing-fixed relative z-10 h-full max-h-none min-h-0 w-0 flex-1 resize-none rounded-none border-0 px-3 py-2 font-mono text-xs leading-5 focus-visible:ring-0 md:text-xs" +
|
||||
(syntaxHighlight
|
||||
? " bg-transparent text-transparent caret-foreground selection:bg-primary selection:text-primary-foreground placeholder:text-muted-foreground"
|
||||
: "")
|
||||
}
|
||||
/>
|
||||
{syntaxHighlight ? (
|
||||
<div
|
||||
ref={syntax}
|
||||
aria-hidden="true"
|
||||
className="pointer-events-none absolute inset-y-0 right-0 left-12 z-20 overflow-hidden px-3 py-2 font-mono text-xs leading-5"
|
||||
>
|
||||
<HttpPacketSyntax
|
||||
raw={value}
|
||||
lineNumbers={false}
|
||||
wrapLines={wrapLines}
|
||||
emptyFallback=""
|
||||
lineClassName="leading-5"
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
|
||||
@@ -112,7 +112,7 @@ export function RepeaterWorkspace() {
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex min-h-10 min-w-0 items-center rounded-md border bg-muted/20 p-1">
|
||||
<div className="scrollbar-none flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
|
||||
<div className="flex min-w-0 flex-1 items-center gap-1 overflow-x-auto">
|
||||
{workspace.tabs.map((tab) => {
|
||||
const selected = tab.id === workspace.activeTabId;
|
||||
return (
|
||||
|
||||
@@ -53,10 +53,9 @@ export function TaskLLMProfileChain({
|
||||
inputId,
|
||||
portalContainer,
|
||||
}: TaskLLMProfileChainProps) {
|
||||
const chatProfiles = React.useMemo(() => profiles.filter((profile) => profile.format !== "jev"), [profiles]);
|
||||
const profilesByID = React.useMemo(() => new Map(chatProfiles.map((profile) => [profile.id, profile])), [chatProfiles]);
|
||||
const itemIDs = React.useMemo(() => chatProfiles.map((profile) => profile.id), [chatProfiles]);
|
||||
const profilesUnavailable = chatProfiles.length === 0;
|
||||
const profilesByID = React.useMemo(() => new Map(profiles.map((profile) => [profile.id, profile])), [profiles]);
|
||||
const itemIDs = React.useMemo(() => profiles.map((profile) => profile.id), [profiles]);
|
||||
const profilesUnavailable = profiles.length === 0;
|
||||
const currentProfileID = activeProfileId && value.includes(activeProfileId) ? activeProfileId : value[0];
|
||||
const currentIndex = value.indexOf(currentProfileID);
|
||||
// api_key_hint is display-only and is empty for valid keys shorter than four
|
||||
|
||||
@@ -14,7 +14,7 @@ function InputGroup({ className, ...props }: React.ComponentProps<"div">) {
|
||||
data-slot="input-group"
|
||||
role="group"
|
||||
className={cn(
|
||||
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-1 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/30 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||
"group/input-group relative flex h-8 w-full min-w-0 items-center rounded-lg border border-input transition-colors outline-none in-data-[slot=combobox-content]:focus-within:border-inherit in-data-[slot=combobox-content]:focus-within:ring-0 has-disabled:bg-input/50 has-disabled:opacity-50 has-[[data-slot=input-group-control]:focus-visible]:border-ring has-[[data-slot=input-group-control]:focus-visible]:ring-3 has-[[data-slot=input-group-control]:focus-visible]:ring-ring/50 has-[[data-slot][aria-invalid=true]]:border-destructive has-[[data-slot][aria-invalid=true]]:ring-3 has-[[data-slot][aria-invalid=true]]:ring-destructive/20 has-[>[data-align=block-end]]:h-auto has-[>[data-align=block-end]]:flex-col has-[>[data-align=block-start]]:h-auto has-[>[data-align=block-start]]:flex-col has-[>textarea]:h-auto dark:bg-input/30 dark:has-disabled:bg-input/80 dark:has-[[data-slot][aria-invalid=true]]:ring-destructive/40 has-[>[data-align=block-end]]:[&>input]:pt-3 has-[>[data-align=block-start]]:[&>input]:pb-3 has-[>[data-align=inline-end]]:[&>input]:pr-1.5 has-[>[data-align=inline-start]]:[&>input]:pl-1.5",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
+3
-24
@@ -116,27 +116,8 @@ import type {
|
||||
UsageStats,
|
||||
WorkspaceFile,
|
||||
WorkspaceListing,
|
||||
SystemMetrics,
|
||||
} from "@/lib/types";
|
||||
|
||||
/**
|
||||
* Read a download name from Content-Disposition. RFC 5987's filename* is
|
||||
* preferred because it preserves UTF-8 names; older filename remains the
|
||||
* fallback for clients that do not understand it.
|
||||
*/
|
||||
export function downloadFilename(contentDisposition: string, fallback: string): string {
|
||||
const encoded = contentDisposition.match(/(?:^|;)\s*filename\*\s*=\s*UTF-8''([^;]+)/i)?.[1];
|
||||
if (encoded) {
|
||||
try {
|
||||
return decodeURIComponent(encoded.replace(/^"|"$/g, ""));
|
||||
} catch {
|
||||
// Fall through to the legacy token when a malformed header is received.
|
||||
}
|
||||
}
|
||||
const legacy = contentDisposition.match(/(?:^|;)\s*filename\s*=\s*(?:"([^"]*)"|([^;\s]+))/i);
|
||||
return (legacy?.[1] ?? legacy?.[2] ?? fallback).trim() || fallback;
|
||||
}
|
||||
|
||||
function getToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
return localStorage.getItem("artex_token");
|
||||
@@ -311,7 +292,6 @@ function schedulePayload(input: ScheduleInput) {
|
||||
export const api = {
|
||||
// 后端应用版本号(release 时由 ldflags 注入,默认 "dev")。
|
||||
health: () => get<{ ok: boolean; service: string; version: string }>("/health"),
|
||||
systemMetrics: () => get<SystemMetrics>("/system/metrics"),
|
||||
|
||||
// ---- auth ----
|
||||
authStatus: () => get<{ initialized: boolean }>("/auth/status"),
|
||||
@@ -552,7 +532,6 @@ export const api = {
|
||||
},
|
||||
workspaceDownload: async (path: string) => {
|
||||
let blob: Blob;
|
||||
let contentDisposition = "";
|
||||
if (MOCK) {
|
||||
blob = new Blob([`(demo)${path} 的下载内容示例。`], { type: "text/plain" });
|
||||
} else {
|
||||
@@ -561,13 +540,12 @@ export const api = {
|
||||
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
});
|
||||
if (!r.ok) throw new Error(`下载失败: ${r.status}`);
|
||||
contentDisposition = r.headers.get("Content-Disposition") ?? "";
|
||||
blob = await r.blob();
|
||||
}
|
||||
const objUrl = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = objUrl;
|
||||
a.download = downloadFilename(contentDisposition, path.split("/").pop() || "download");
|
||||
a.download = path.split("/").pop() || "download";
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
a.remove();
|
||||
@@ -723,7 +701,8 @@ export const api = {
|
||||
const blob = await r.blob();
|
||||
// 文件名优先取后端 Content-Disposition,取不到则用默认名。
|
||||
const disp = r.headers.get("Content-Disposition") ?? "";
|
||||
const filename = downloadFilename(disp, `findings-export`);
|
||||
const m = disp.match(/filename="?([^"]+)"?/);
|
||||
const filename = m?.[1] ?? `findings-export`;
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement("a");
|
||||
a.href = url;
|
||||
|
||||
+1
-20
@@ -529,7 +529,6 @@ export interface Finding {
|
||||
summary: string;
|
||||
evidence: string;
|
||||
report?: string; // 详细报告(Markdown);仅详情接口返回,列表为空
|
||||
report_conversation_id?: number;
|
||||
intent_id?: string;
|
||||
param_id?: string;
|
||||
task_id?: string;
|
||||
@@ -1109,7 +1108,7 @@ export interface NotificationDelivery {
|
||||
export interface LLMProfile {
|
||||
id: string;
|
||||
name: string;
|
||||
format: "openai" | "anthropic" | "openai-responses" | "jev";
|
||||
format: "openai" | "anthropic" | "openai-responses";
|
||||
base_url?: string;
|
||||
proxy?: string;
|
||||
model: string;
|
||||
@@ -1332,23 +1331,6 @@ export interface Stats {
|
||||
active_task?: Partial<Task>;
|
||||
}
|
||||
|
||||
export interface SystemMetricValue {
|
||||
used_bytes: number;
|
||||
total_bytes: number;
|
||||
percent: number;
|
||||
}
|
||||
|
||||
export interface SystemMetrics {
|
||||
scope: "container" | "unsupported" | string;
|
||||
supported: boolean;
|
||||
timestamp: string;
|
||||
container_metrics: boolean;
|
||||
cpu: SystemMetricValue;
|
||||
memory: SystemMetricValue;
|
||||
disk: SystemMetricValue;
|
||||
data_disk: SystemMetricValue;
|
||||
}
|
||||
|
||||
// ---- Intercept Rules ----
|
||||
export type InterceptAction = "allow" | "deny" | "ask";
|
||||
export type InterceptMatchTarget = "tool_name" | "tool_input";
|
||||
@@ -1433,7 +1415,6 @@ export interface JudgeConfig {
|
||||
ask_timeout_seconds: number; // 模型判 ask 转人工后的审批等待超时
|
||||
ask_timeout_action: "allow" | "deny"; // 审批超时后的默认动作
|
||||
incomplete_script_action: "ask" | "model"; // 脚本快照不完整时:人工审查或继续模型审查
|
||||
confidence_threshold: number; // Jev 置信度低于此值时转人工
|
||||
}
|
||||
|
||||
// JudgeUsage: 模型兜底审批(judge 通道)的累计 token 用量 + 近 N 天每日序列。
|
||||
|
||||
Reference in new issue
Block a user