merge: sync official main into PR branch

This commit is contained in:
pphua committed 2026-08-28 14:14:41 +08:00
commit d803ae2b11
23 files changed
+1325 -68

No files matched your search

+2 -1
View File
@@ -286,7 +286,8 @@
- [@Autumn-27](https://github.com/Autumn-27)
- [@neouks](https://github.com/neouks)
[Unreleased]: https://github.com/Autumn-27/ARTEX/compare/v0.3.4...HEAD
[Unreleased]: https://github.com/Autumn-27/ARTEX/compare/v0.3.5...HEAD
[0.3.5]: https://github.com/Autumn-27/ARTEX/compare/v0.3.4...v0.3.5
[0.3.4]: https://github.com/Autumn-27/ARTEX/compare/v0.3.3...v0.3.4
[0.3.3]: https://github.com/Autumn-27/ARTEX/compare/v0.3.2...v0.3.3
[0.3.2]: https://github.com/Autumn-27/ARTEX/compare/v0.3.1...v0.3.2
+5 -1
View File
@@ -242,7 +242,10 @@ const plannerDefaultTmpl = `你是一个授权渗透测试系统的"规划者"
任务目标:{{.Goal}}
⚠️ 最重要的原则:**这一轮生成 0 个意图是完全正常、而且是最常见的结果。** 你不是"每次都要产出意图"的机器。绝大多数唤醒,frontier 里已有的意图已经覆盖了所有已知方向,你应当什么都不加、直接结束。重复/换措辞地生成已经存在的意图是严重错误。
⚠️ **生成 0 个意图是合法、正常的结果,但它有【具体的正当理由】,不是"少派更稳"的默认姿态。** 只在下列情况才该产出 0 个意图:
- **已覆盖**:你想到的方向都已被 open / running / recent_done 意图覆盖——重复或换措辞地再生成已存在的意图是严重错误;或
- **等待依赖**:下一步依赖【当前正在运行的 work】的产出,而产出还没出来——此时应【等这些 work 跑完、图更新后的下一次唤醒再规划】,现在硬派会让下游 worker 拿不到还不存在的前置而空转。
反过来:若确有【未被任何意图覆盖、且不依赖在跑 work】的新方向,或【目标尚未达成且范围内仍有未测面】,就【不要】因为"0 意图常见"而收手——该派就派。别把 0 意图当成偷懒的默认。
决策流程(每次唤醒):
1. **完整态势已直接附在本提示下方(就是 graph_overview 的返回,无需再调它)**:task(**原始任务标题+目标**,即根节点)、资产计数、goals 及其状态、open/running/recent_done 意图、sites_without_endpoints、findings(**确认漏洞**数)、facts(**探索事实/结论**数,与漏洞是两类)、recent_facts(最近事实的 {id, summary, confidence?},含"端口关闭/不可注入"等**否定结论**——据此别再为已探明的死路生成意图;但 confidence=inferred 的否定结论只是【推断】、证据弱,别当铁案,若该方向对目标很关键值得派一条复核意图)。**这里的探索节点(goals/意图/facts/findings)都只含本任务的**(绝不会有别的任务的目标);**资产图则全局共享**(多任务同一份,资产计数是全局在范围内的数据,非本任务独有)。需要更深的细节时才按需调:list_facts 分页列事实(最新在前,默认 20 条,可传 q 关键词过滤、before 翻页,返回带 total/has_more)、list_findings 列全部漏洞、**node_detail(id)** 取某条的完整证据/详情(列表/recent_facts 只给摘要)。
@@ -250,6 +253,7 @@ const plannerDefaultTmpl = `你是一个授权渗透测试系统的"规划者"
- sites_without_endpoints / findings——"哪些方向【可能】需要探索"。
- 只有需要某一片的细节时,才**按需**调 list_assets(pull 模式:可用 q 关键字搜索,可叠加 type/company_id/task_id 过滤,分页 limit/offset;或用 id/ids 直接取)、asset_neighbors、list_findings。资产图全局共享,别默认拉全量。资产中可能包含非本次任务涉及到的资产,所以需要主要出现非本次任务相关的资产时忽略这些资产。
2. 判目标(核心职责):graph_overview 的 goals 字段已含目标与状态;对已被某发现/事实证明的未达成目标,调 prove_goal(goal_id,evidence_id,reason) 标记 met。**当你用 prove_goal 标记的这一个恰好是最后一个未完成目标时,系统会自动判定整个任务完成**——收官完全由逐个 prove_goal 驱动,你无需、也没有别的“一键完成”手段。
⚠️ **量化验收核对(严禁提前盖章)**:若某目标含【可量化的验收条件】(如"资产测试覆盖度达到 X%"、"拿到 N 个 flag"、"获得某权限"),prove_goal **前【必须】核对本提示上方 graph_overview 里的实测值**(coverage.pct、findings 计数等):实测【未达标】就【禁止】prove_goal,改为派意图补足差距——**【不得】以"主要部分已完成/大体达成/核心目标已拿下"为由提前标 met**。例:目标要求覆盖度 100%、而 graph_overview 实测 coverage.pct=40%,则该目标【未达成】,继续派补测意图,不许 prove_goal。
2.5. **(可选,仅限开局、极轻量)探测理解**:你具备 Bash 等执行能力,它的**唯一正当用途**是——当**图里几乎还没有事实**(recent_facts 基本为空、任务刚开始)、仅凭态势无法把初始意图描述具体时,对目标做**极少量、只读**的探测(如 1–2 次 curl 看首页/指纹),据此产出更精准的**初始意图**。
⚠️ **牢记你的身份边界:你是"规划者",不是"执行者"。你在这里做的一切都只为【生成/说清意图】,绝不是【在 plan 里把活干了】。** 探测的**唯一合法产物是一句更精准的意图描述**——绝不能是漏洞的发现、验证、利用,也不能是端点/目录/参数的枚举结果。任何"我顺手把这个也测了/确认了"的念头都是越界:那是 worker 在 work 阶段该做的事,你只需把它**写成一个意图派下去**。
**三条硬性边界,务必守住**:
+53 -2
View File
@@ -19,6 +19,7 @@ import (
"strings"
"time"
"github.com/Autumn-27/artex/llmrec"
"github.com/Autumn-27/norma/agentcore"
"github.com/Autumn-27/norma/compaction"
"github.com/Autumn-27/norma/llm"
@@ -131,6 +132,12 @@ func FromEnv() (Config, bool) {
if c.Model == "" {
c.Model = "gpt-4o"
}
case "openai-responses":
c.Format = llm.FormatOpenAIResponses
c.APIKey = oaiKey
if c.Model == "" {
c.Model = "gpt-5"
}
default:
c.Format = llm.FormatAnthropic
c.APIKey = anthKey
@@ -164,6 +171,13 @@ func ConfigFrom(provider, model, baseURL, apiKey, proxy string) Config {
if c.Model == "" {
c.Model = "gpt-4o"
}
case "openai-responses":
c.Format = llm.FormatOpenAIResponses
// provider appends "/responses"; tolerate a full endpoint URL.
c.BaseURL = strings.TrimRight(strings.TrimSuffix(c.BaseURL, "/responses"), "/")
if c.Model == "" {
c.Model = "gpt-5"
}
default:
c.Format = llm.FormatAnthropic
// provider appends "/v1/messages".
@@ -187,8 +201,11 @@ func isFalsy(s string) bool {
// Provider returns the short provider name ("anthropic"/"openai").
func (c Config) Provider() string {
if c.Format == llm.FormatOpenAI {
switch c.Format {
case llm.FormatOpenAI:
return "openai"
case llm.FormatOpenAIResponses:
return "openai-responses"
}
return "anthropic"
}
@@ -265,10 +282,25 @@ func IsQuotaExhaustedMessage(message string) bool {
type quotaAwareTransport struct{ base http.RoundTripper }
func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error) {
// When LLM recording is on, the Recorder puts a Capture on the context so the
// raw wire bodies can be persisted. This is the only layer that still sees
// them: norma builds the request body internally and decodes the SSE response
// before either reaches the recorder.
capt := llmrec.CaptureFrom(req.Context())
capt.SetRequest(requestBodySnapshot(req))
resp, err := t.base.RoundTrip(req)
if err != nil || resp == nil || resp.StatusCode != http.StatusTooManyRequests {
if err != nil || resp == nil {
return resp, err
}
// Tee rather than read: a 200 is an SSE stream that must keep streaming. The
// 429 branch below reads through this wrapper, so its body lands in the
// capture before being replaced.
resp.Body = capt.TeeResponse(resp.StatusCode, resp.Body)
if resp.StatusCode != http.StatusTooManyRequests {
return resp, nil
}
body, readErr := io.ReadAll(resp.Body)
_ = resp.Body.Close()
resp.Body = io.NopCloser(bytes.NewReader(body))
@@ -283,6 +315,25 @@ func (t quotaAwareTransport) RoundTrip(req *http.Request) (*http.Response, error
return resp, nil
}
// requestBodySnapshot copies an outgoing request body without consuming it.
// norma builds every model request from a *bytes.Reader, so net/http populates
// GetBody and the copy has no effect on what gets sent.
func requestBodySnapshot(req *http.Request) string {
if req.GetBody == nil {
return ""
}
rc, err := req.GetBody()
if err != nil {
return ""
}
defer rc.Close()
b, err := io.ReadAll(rc)
if err != nil {
return ""
}
return string(b)
}
func quotaAwareHTTPClient(proxy string) (*http.Client, error) {
transport := http.DefaultTransport.(*http.Transport).Clone()
proxy = strings.TrimSpace(proxy)
+131
View File
@@ -0,0 +1,131 @@
package agent
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Autumn-27/artex/llmrec"
"github.com/Autumn-27/norma/llm"
)
// End-to-end through a real norma provider: the Capture rides the context into
// norma, survives its internal request building, and comes back holding the
// exact bytes buildBody() put on the wire. This is the load-bearing assumption
// of the whole feature — norma must propagate the caller's context down to
// http.NewRequestWithContext.
func TestCapturePropagatesThroughNormaProvider(t *testing.T) {
sse := strings.Join([]string{
`event: message_start`,
`data: {"type":"message_start","message":{"id":"msg_1","usage":{"input_tokens":11,"output_tokens":1}}}`,
``,
`event: content_block_delta`,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"hello"}}`,
``,
`event: message_delta`,
`data: {"type":"message_delta","delta":{"stop_reason":"end_turn"},"usage":{"output_tokens":7}}`,
``,
`event: message_stop`,
`data: {"type":"message_stop"}`,
``,
}, "\n")
// Record exactly what the server receives, so the capture can be compared
// against it byte for byte rather than merely spot-checked for fields.
var gotPath, serverSaw string
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
gotPath = r.URL.Path
b, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("server read body: %v", err)
}
serverSaw = string(b)
w.Header().Set("content-type", "text/event-stream")
_, _ = io.WriteString(w, sse)
}))
defer srv.Close()
cfg := Config{
Format: llm.FormatAnthropic,
BaseURL: srv.URL,
APIKey: "test-key",
Model: "claude-test",
}
prov, err := cfg.NewProvider()
if err != nil {
t.Fatalf("NewProvider: %v", err)
}
ctx, capt := llmrec.NewCapture(context.Background())
req := llm.CompletionRequest{
System: []string{"you are a scanner"},
Messages: []llm.Message{{Role: "user", Content: []llm.ContentBlock{{Type: "text", Text: "go"}}}},
Tools: []llm.ToolSchema{{
Name: "bash",
Description: "run a shell command",
InputSchema: map[string]any{"type": "object", "properties": map[string]any{"command": map[string]any{"type": "string"}}},
}},
MaxTokens: 1024,
}
var text strings.Builder
for ev, err := range prov.Stream(ctx, req) {
if err != nil {
t.Fatalf("stream: %v", err)
}
if ev.Type == llm.SETextDelta {
text.WriteString(ev.Text)
}
}
if text.String() != "hello" {
t.Fatalf("stream text=%q, capture interfered with delivery", text.String())
}
if gotPath != "/v1/messages" {
t.Fatalf("path=%q", gotPath)
}
// The raw request must be what norma actually sent, not the recorder's
// re-serialization — which is exactly why it carries fields the normalized
// view drops.
raw := capt.RawRequest()
if raw == "" {
t.Fatal("no raw request captured — context did not reach the transport")
}
// The load-bearing claim: what we stored equals, byte for byte, what the
// server received — not merely "has the right fields".
if raw != serverSaw {
t.Fatalf("captured request != what the server received:\n got: %q\nsaw: %q", raw, serverSaw)
}
var body map[string]any
if err := json.Unmarshal([]byte(raw), &body); err != nil {
t.Fatalf("raw request is not valid JSON: %v\n%s", err, raw)
}
if body["model"] != "claude-test" {
t.Errorf("model=%v want claude-test (absent from CompletionRequest)", body["model"])
}
if body["stream"] != true {
t.Errorf("stream=%v want true", body["stream"])
}
// The full tool schema is the headline gain: the normalized view keeps names only.
tools, _ := body["tools"].([]any)
if len(tools) != 1 {
t.Fatalf("tools=%v", body["tools"])
}
tool, _ := tools[0].(map[string]any)
if tool["description"] != "run a shell command" {
t.Errorf("tool description missing: %v", tool)
}
if tool["input_schema"] == nil {
t.Errorf("tool input_schema missing: %v", tool)
}
// And the response is the untouched SSE frames, including the events the
// recorder never turns into stored output.
if capt.RawResponse() != sse {
t.Errorf("RawResponse mismatch:\n got: %q\nwant: %q", capt.RawResponse(), sse)
}
}
+125
View File
@@ -0,0 +1,125 @@
package agent
import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Autumn-27/artex/llmrec"
)
// The transport is the only layer that still sees the wire bodies: norma builds
// the request body internally and decodes the SSE response before the recorder
// gets it. This checks the round trip preserves both directions untouched.
func TestRoundTripCapturesRawBodies(t *testing.T) {
const sse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\nevent: message_stop\ndata: {}\n\n"
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "text/event-stream")
_, _ = io.WriteString(w, sse)
}))
defer srv.Close()
client, err := quotaAwareHTTPClient("")
if err != nil {
t.Fatalf("client: %v", err)
}
const reqBody = `{"model":"claude","messages":[{"role":"user","content":"hi"}],"tools":[{"name":"t","input_schema":{}}]}`
req, err := http.NewRequest(http.MethodPost, srv.URL, strings.NewReader(reqBody))
if err != nil {
t.Fatalf("request: %v", err)
}
ctx, capt := llmrec.NewCapture(req.Context())
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
got, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read: %v", err)
}
_ = resp.Body.Close()
if string(got) != sse {
t.Fatal("capture altered the response delivered to norma")
}
if capt.RawRequest() != reqBody {
t.Fatalf("RawRequest()=%q want %q", capt.RawRequest(), reqBody)
}
if capt.RawResponse() != sse {
t.Fatalf("RawResponse()=%q want the raw SSE frames", capt.RawResponse())
}
}
// A 429 body is read and replaced in-place by the quota check. Capturing must
// still see it, and the replacement body must remain readable downstream.
func TestRoundTripCaptures429BodyAlongsideQuotaRewrite(t *testing.T) {
const body = `{"error":{"message":"insufficient_quota"}}`
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusTooManyRequests)
_, _ = io.WriteString(w, body)
}))
defer srv.Close()
client, err := quotaAwareHTTPClient("")
if err != nil {
t.Fatalf("client: %v", err)
}
req, err := http.NewRequest(http.MethodPost, srv.URL, strings.NewReader("{}"))
if err != nil {
t.Fatalf("request: %v", err)
}
ctx, capt := llmrec.NewCapture(req.Context())
req = req.WithContext(ctx)
resp, err := client.Do(req)
if err != nil {
t.Fatalf("do: %v", err)
}
defer resp.Body.Close()
// Quota exhaustion is normalized to 402 so the router fails over.
if resp.StatusCode != http.StatusPaymentRequired {
t.Fatalf("status=%d want 402", resp.StatusCode)
}
if capt.RawResponse() != body {
t.Fatalf("RawResponse()=%q want %q", capt.RawResponse(), body)
}
rest, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read replaced body: %v", err)
}
if string(rest) != body {
t.Fatalf("replaced body=%q want it still readable", rest)
}
}
// Recording off = no Capture on the context. The transport must behave exactly
// as before, including the quota rewrite.
func TestRoundTripWithoutCaptureIsUnchanged(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.WriteString(w, "ok")
}))
defer srv.Close()
client, err := quotaAwareHTTPClient("")
if err != nil {
t.Fatalf("client: %v", err)
}
resp, err := client.Post(srv.URL, "application/json", strings.NewReader("{}"))
if err != nil {
t.Fatalf("post: %v", err)
}
defer resp.Body.Close()
got, err := io.ReadAll(resp.Body)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(got) != "ok" {
t.Fatalf("body=%q", got)
}
}
+30
View File
@@ -0,0 +1,30 @@
package agent
import (
"testing"
"github.com/Autumn-27/norma/llm"
)
// TestConfigFromOpenAIResponses locks the openai-responses format wiring:
// ConfigFrom resolves the Responses format, strips a full /responses endpoint
// back to the API base, and Provider() round-trips the short name. NewProvider
// must build a working provider for it.
func TestConfigFromOpenAIResponses(t *testing.T) {
c := ConfigFrom("openai-responses", "gpt-5", "https://gw.example/v1/responses", "sk-x", "")
if c.Format != llm.FormatOpenAIResponses {
t.Fatalf("format=%v, want FormatOpenAIResponses", c.Format)
}
if c.BaseURL != "https://gw.example/v1" {
t.Fatalf("base_url=%q, want the /responses suffix stripped", c.BaseURL)
}
if c.Provider() != "openai-responses" {
t.Fatalf("Provider()=%q", c.Provider())
}
if !c.Stream { // default streaming preserved
t.Fatal("Stream should default true")
}
if _, err := c.NewProvider(); err != nil {
t.Fatalf("NewProvider: %v", err)
}
}
+1 -1
View File
@@ -169,7 +169,7 @@ const workerDefaultTmpl = `你是一个授权渗透测试系统的"执行者"(wo
可用工具:
- insert_assets:登记新资产(资产图)。**你只传原始信息,key 与父子关联由代码算**:新接口→传完整 url+method(代码自动建 domain→site→endpoint、自动抽 URL 里的 query 参数,body/header 参数放 params);指纹→type=tech,name=技术名,on_url=站点地址,props填{version,category}。多个资产放 assets 数组一次批量登记。属性写在资产自己的 props 上,探索结论不要写这里。
- record_fact:把探索【事实/结论】写入探索图并连到意图(传 intent_id)。正向/否定结论、观察、判断用它;**一次探索的多个观察汇总成一条事实**(summary 总结一句话 + detail 放细节),不要一个属性一条。真有多条不同结论才用 facts 数组。**只写你真实看到的**:给 evidence(一行关键证据:命令+关键输出,简洁,别粘大段——细节在 detail)、标 confidence(observed 直接看到 / inferred 推断);**否定结论**(不可注入/端口关闭等)尤其要给证据、证据弱就标 inferred,别让错的否定误导规划者放弃方向。
- record_fact:把探索【事实/结论】写入探索图并连到意图(传 intent_id)。正向/否定结论、观察、判断用它;**一次探索的多个观察汇总成一条事实**(summary 总结一句话 + detail 放细节),不要一个属性一条。真有多条不同结论才用 facts 数组。**只写你真实看到的**:给 evidence(一行关键证据:命令+关键输出,简洁,别粘大段——细节在 detail)、标 confidence(observed 直接看到 / inferred 推断);**否定结论的证据门槛**(不可注入/端口关闭/无登录入口等【可能让规划者放弃一整条方向】的结论):下结论前先确认你已【穷尽这条意图内的合理手段】(换编码/换参数/换路径/换方法);手段没走完、或证据只是"看起来像/大概率如此",一律标 confidence=inferred。**宁可标 inferred 让规划者复核,也别用一个轻率的 observed 否定把一整条路线焊死**——尤其任务早期,一个错误的 observed 否定会把整个任务带偏、且后续很难自己扳回来。
- report_finding:确认漏洞 → 记录(含 PoC,传 intent_id=你领到的意图id)。**只有你在本次运行里真实触发过该漏洞、拿到了可复现的证据(请求/响应或命令输出)才用它。** 严禁把下列当作已确认漏洞上报:仅凭版本号/指纹匹配到某 CVE、仅凭"参数看起来可注入"、仅凭外部漏洞库/更新日志/代码 diff 推断。**不要用查 CVE 库或"对比补丁版本"替代实际触发。** 触发不了但确有嫌疑,就用 record_fact 记一条 confidence=inferred 的事实(描述嫌疑点+为何未能触发),交给规划者派后续意图,别硬记成 finding。
- list_assets(查询资产,非探索节点) / asset_neighbors / list_facts(探索事实,分页最新在前;默认 20 条,可传 q 关键词过滤、before 翻页) / list_findings(漏洞) / node_detail(探索节点 id,非资产 id):按需查上下文。
+21 -7
View File
@@ -144,6 +144,13 @@ type LLMRecord struct {
Error string `json:"error,omitempty"`
RequestBody string `json:"request_body,omitempty"`
ResponseBody string `json:"response_body,omitempty"`
// RawRequest / RawResponse are the untouched HTTP bodies exchanged with the
// provider — the request as buildBody() sent it (full tool schemas included)
// and the raw SSE frames. RequestBody/ResponseBody above are the normalized
// view, which drops tool schemas and tool_use blocks entirely. Empty for
// records written before this was added, or when the call never reached HTTP.
RawRequest string `json:"raw_request,omitempty"`
RawResponse string `json:"raw_response,omitempty"`
}
const llmRecordsSchema = `
@@ -163,7 +170,9 @@ CREATE TABLE IF NOT EXISTS llm_records (
status TEXT,
error TEXT,
request_body TEXT,
response_body TEXT
response_body TEXT,
raw_request TEXT,
raw_response TEXT
);
CREATE INDEX IF NOT EXISTS idx_llm_records_ts ON llm_records(ts);
CREATE INDEX IF NOT EXISTS idx_llm_records_session ON llm_records(session_id);
@@ -174,6 +183,8 @@ const llmRecordsMigrate = `
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS task_id TEXT;
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS worker TEXT;
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS profile_name TEXT;
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS raw_request TEXT;
ALTER TABLE llm_records ADD COLUMN IF NOT EXISTS raw_response TEXT;
`
// EnsureLLMRecordsTable creates the llm_records table if it does not exist.
@@ -188,11 +199,12 @@ func (d *DB) EnsureLLMRecordsTable() error {
// InsertLLMRecord stores one LLM call record.
func (d *DB) InsertLLMRecord(r *LLMRecord) error {
_, err := d.Exec(`
INSERT INTO llm_records(model, profile_name, session_id, task_id, worker, latency_ms, input_tokens, output_tokens, cache_read, cache_write, status, error, request_body, response_body)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14)`,
INSERT INTO llm_records(model, profile_name, session_id, task_id, worker, latency_ms, input_tokens, output_tokens, cache_read, cache_write, status, error, request_body, response_body, raw_request, raw_response)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16)`,
r.Model, nullIfEmpty(r.ProfileName), r.SessionID, nullIfEmpty(r.TaskID), nullIfEmpty(r.Worker),
r.LatencyMs, r.InputTokens, r.OutputTokens, r.CacheRead, r.CacheWrite,
r.Status, nullIfEmpty(r.Error), nullIfEmpty(r.RequestBody), nullIfEmpty(r.ResponseBody))
r.Status, nullIfEmpty(r.Error), nullIfEmpty(r.RequestBody), nullIfEmpty(r.ResponseBody),
nullIfEmpty(r.RawRequest), nullIfEmpty(r.RawResponse))
return err
}
@@ -305,19 +317,21 @@ func (d *DB) DeleteLLMRecords(task string) (int64, error) {
// GetLLMRecord returns a single LLM record with full request/response bodies.
func (d *DB) GetLLMRecord(id int64) (*LLMRecord, error) {
var r LLMRecord
var reqBody, respBody sql.NullString
var reqBody, respBody, rawReq, rawResp sql.NullString
err := d.QueryRow(`SELECT id, ts, COALESCE(model,''), COALESCE(profile_name,''), COALESCE(session_id,''), COALESCE(task_id,''), COALESCE(worker,''),
COALESCE(latency_ms,0), COALESCE(input_tokens,0), COALESCE(output_tokens,0), COALESCE(cache_read,0), COALESCE(cache_write,0),
COALESCE(status,''), COALESCE(error,''), request_body, response_body
COALESCE(status,''), COALESCE(error,''), request_body, response_body, raw_request, raw_response
FROM llm_records WHERE id=$1`, id).
Scan(&r.ID, &r.Ts, &r.Model, &r.ProfileName, &r.SessionID, &r.TaskID, &r.Worker, &r.LatencyMs,
&r.InputTokens, &r.OutputTokens, &r.CacheRead, &r.CacheWrite, &r.Status, &r.Error,
&reqBody, &respBody)
&reqBody, &respBody, &rawReq, &rawResp)
if err != nil {
return nil, err
}
r.RequestBody = reqBody.String
r.ResponseBody = respBody.String
r.RawRequest = rawReq.String
r.RawResponse = rawResp.String
return &r, nil
}
+62
View File
@@ -0,0 +1,62 @@
package db
import (
"testing"
)
// The raw-body columns were added after release, so existing installs only get
// them through llmRecordsMigrate. This runs the real migration against the dev
// PG on a table stripped back to its pre-upgrade shape, inside a transaction
// that is always rolled back — PG does transactional DDL, so nothing persists.
func TestLLMRecordsMigrateAddsRawColumnsToOldTable(t *testing.T) {
d, err := Open(testDSN(t))
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
defer d.Close()
if err := d.EnsureLLMRecordsTable(); err != nil {
t.Fatalf("ensure: %v", err)
}
tx, err := d.Begin()
if err != nil {
t.Fatalf("begin: %v", err)
}
defer tx.Rollback() //nolint:errcheck // the test never commits
// Roll the table back to how a pre-upgrade install looks.
if _, err := tx.Exec(`ALTER TABLE llm_records DROP COLUMN IF EXISTS raw_request, DROP COLUMN IF EXISTS raw_response`); err != nil {
t.Fatalf("simulate old table: %v", err)
}
if _, err := tx.Exec(llmRecordsMigrate); err != nil {
t.Fatalf("migrate: %v", err)
}
for _, col := range []string{"raw_request", "raw_response"} {
var n int
if err := tx.QueryRow(
`SELECT count(*) FROM information_schema.columns
WHERE table_name='llm_records' AND column_name=$1`, col).Scan(&n); err != nil {
t.Fatalf("inspect %s: %v", col, err)
}
if n != 1 {
t.Errorf("column %s missing after migrate", col)
}
}
// Re-running must stay a no-op (the migration runs on every startup).
if _, err := tx.Exec(llmRecordsMigrate); err != nil {
t.Fatalf("migrate is not idempotent: %v", err)
}
// An insert carrying raw bodies must round-trip through the migrated table.
var got string
if err := tx.QueryRow(
`INSERT INTO llm_records(model, raw_request, raw_response) VALUES ('m','{"a":1}','data: x')
RETURNING raw_response`).Scan(&got); err != nil {
t.Fatalf("insert into migrated table: %v", err)
}
if got != "data: x" {
t.Errorf("raw_response=%q want %q", got, "data: x")
}
}
+1 -1
View File
@@ -291,7 +291,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')),
format TEXT NOT NULL CHECK (format IN ('openai','anthropic','openai-responses')),
base_url TEXT,
proxy TEXT,
model TEXT NOT NULL,
+140
View File
@@ -0,0 +1,140 @@
package llmrec
import (
"context"
"fmt"
"io"
"strings"
"sync"
)
type captureContextKey struct{}
// Capture collects the untouched wire bodies of one logical LLM call. The
// Recorder creates it and puts it on the context; the HTTP transport that norma
// dials through (agent.quotaAwareTransport) finds it there and fills it in.
//
// This exists because everything the Recorder itself sees is already normalized:
// llm.CompletionRequest is re-serialized rather than the body buildBody() sent,
// and the response arrives as decoded StreamEvents, not the SSE frames. For
// debugging a live provider, the bytes on the wire are the only ground truth.
//
// norma's doStream retries the request-establishment phase, so one Stream can
// issue several HTTP attempts. Each attempt is kept: the discarded ones (see
// norma/llm/retry.go, which closes non-final bodies unread) are exactly what
// makes rate-limit and gateway failures diagnosable.
//
// The transport writes from norma's stream-reading goroutine while the Recorder
// snapshots at stream end, so all state is mutex-guarded.
type Capture struct {
mu sync.Mutex
request string
attempts []*attempt
}
type attempt struct {
status int
body strings.Builder
}
// NewCapture returns a context carrying a fresh Capture, plus the Capture itself.
func NewCapture(ctx context.Context) (context.Context, *Capture) {
c := &Capture{}
return context.WithValue(ctx, captureContextKey{}, c), c
}
// CaptureFrom returns the Capture attached to ctx, or nil when raw capture is
// off. Callers must tolerate nil — recording is a toggle, and non-recorded
// providers dial through the same transport.
func CaptureFrom(ctx context.Context) *Capture {
if ctx == nil {
return nil
}
c, _ := ctx.Value(captureContextKey{}).(*Capture)
return c
}
// SetRequest stores the outgoing request body. Retries re-send identical bytes,
// so only the first attempt's body is kept.
func (c *Capture) SetRequest(body string) {
if c == nil || body == "" {
return
}
c.mu.Lock()
defer c.mu.Unlock()
if c.request == "" {
c.request = body
}
}
// TeeResponse opens a new attempt and wraps rc so everything read from it is
// mirrored into that attempt. It tees rather than reads because a successful
// response is an SSE stream that must keep streaming to the caller.
func (c *Capture) TeeResponse(status int, rc io.ReadCloser) io.ReadCloser {
if c == nil || rc == nil {
return rc
}
a := &attempt{status: status}
c.mu.Lock()
c.attempts = append(c.attempts, a)
c.mu.Unlock()
return &teeBody{rc: rc, c: c, a: a}
}
// RawRequest returns the request body as sent, or "" if nothing was captured.
func (c *Capture) RawRequest() string {
if c == nil {
return ""
}
c.mu.Lock()
defer c.mu.Unlock()
return c.request
}
// RawResponse returns the response bytes as received. A single attempt yields
// the untouched original (copy-pasteable straight into a replay); multiple
// attempts are concatenated behind per-attempt header lines so a retry sequence
// stays readable.
func (c *Capture) RawResponse() string {
if c == nil {
return ""
}
c.mu.Lock()
defer c.mu.Unlock()
switch len(c.attempts) {
case 0:
return ""
case 1:
return c.attempts[0].body.String()
}
var b strings.Builder
for i, a := range c.attempts {
fmt.Fprintf(&b, "===== attempt %d/%d — HTTP %d =====\n", i+1, len(c.attempts), a.status)
body := a.body.String()
b.WriteString(body)
if !strings.HasSuffix(body, "\n") {
b.WriteByte('\n')
}
}
return b.String()
}
// teeBody mirrors reads into a Capture attempt, guarded by the Capture's mutex
// so a snapshot taken mid-stream never races the writer.
type teeBody struct {
rc io.ReadCloser
c *Capture
a *attempt
}
func (t *teeBody) Read(p []byte) (int, error) {
n, err := t.rc.Read(p)
if n > 0 {
t.c.mu.Lock()
t.a.body.Write(p[:n])
t.c.mu.Unlock()
}
return n, err
}
func (t *teeBody) Close() error { return t.rc.Close() }
+103
View File
@@ -0,0 +1,103 @@
package llmrec
import (
"context"
"io"
"strings"
"testing"
)
func TestCaptureFromNilAndMissing(t *testing.T) {
var nilCtx context.Context // the transport reaches here on any unrecorded call
if CaptureFrom(nilCtx) != nil {
t.Fatal("nil context should yield no capture")
}
if CaptureFrom(context.Background()) != nil {
t.Fatal("bare context should yield no capture")
}
}
// A nil *Capture is the "recording off" path: the transport calls these on every
// request, so they must all be no-ops rather than panics.
func TestNilCaptureIsInert(t *testing.T) {
var c *Capture
c.SetRequest("body")
if got := c.RawRequest(); got != "" {
t.Fatalf("RawRequest()=%q want empty", got)
}
if got := c.RawResponse(); got != "" {
t.Fatalf("RawResponse()=%q want empty", got)
}
rc := io.NopCloser(strings.NewReader("x"))
if c.TeeResponse(200, rc) != rc {
t.Fatal("nil capture must pass the body through untouched")
}
}
func TestCaptureSingleAttemptKeepsBytesVerbatim(t *testing.T) {
ctx, c := NewCapture(context.Background())
if CaptureFrom(ctx) != c {
t.Fatal("capture not retrievable from its own context")
}
c.SetRequest(`{"model":"x"}`)
// Retries re-send identical bytes; only the first is kept.
c.SetRequest(`{"model":"ignored"}`)
const sse = "event: message_start\ndata: {\"type\":\"message_start\"}\n\nevent: message_stop\ndata: {}\n\n"
body := c.TeeResponse(200, io.NopCloser(strings.NewReader(sse)))
got, err := io.ReadAll(body)
if err != nil {
t.Fatalf("read: %v", err)
}
if string(got) != sse {
t.Fatal("tee altered the stream delivered to the caller")
}
if err := body.Close(); err != nil {
t.Fatalf("close: %v", err)
}
if c.RawRequest() != `{"model":"x"}` {
t.Fatalf("RawRequest()=%q", c.RawRequest())
}
// A lone attempt must stay byte-identical — no headers, no framing added, so
// it can be replayed as-is.
if c.RawResponse() != sse {
t.Fatalf("RawResponse()=%q want verbatim SSE", c.RawResponse())
}
}
// norma's doStream discards the bodies of retried attempts (retry.go closes them
// unread), so the capture is the only place a 429 body survives.
func TestCaptureRetriedAttemptsAreAllKept(t *testing.T) {
_, c := NewCapture(context.Background())
first := c.TeeResponse(429, io.NopCloser(strings.NewReader(`{"error":"rate_limit"}`)))
if _, err := io.ReadAll(first); err != nil {
t.Fatalf("read first: %v", err)
}
second := c.TeeResponse(200, io.NopCloser(strings.NewReader("data: ok\n")))
if _, err := io.ReadAll(second); err != nil {
t.Fatalf("read second: %v", err)
}
raw := c.RawResponse()
if !strings.Contains(raw, `{"error":"rate_limit"}`) {
t.Fatalf("dropped the retried attempt body: %q", raw)
}
if !strings.Contains(raw, "data: ok") {
t.Fatalf("dropped the final attempt body: %q", raw)
}
if !strings.Contains(raw, "attempt 1/2 — HTTP 429") || !strings.Contains(raw, "attempt 2/2 — HTTP 200") {
t.Fatalf("attempts not delimited: %q", raw)
}
if strings.Index(raw, "rate_limit") > strings.Index(raw, "data: ok") {
t.Fatal("attempts stored out of order")
}
}
func TestCaptureEmptyWhenNoHTTPHappened(t *testing.T) {
_, c := NewCapture(context.Background())
if c.RawRequest() != "" || c.RawResponse() != "" {
t.Fatal("unused capture should be empty")
}
}
+27 -13
View File
@@ -110,9 +110,15 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S
}
// Serialize the request only when storing bodies (this is the expensive part).
// Alongside it, attach a Capture so the HTTP transport can hand back the
// untouched wire bodies — the normalized view below cannot reconstruct them
// (tool schemas are dropped, tool_use blocks never reach this layer, and the
// SSE framing is already decoded). See capture.go.
reqBody := ""
var capt *Capture
if recordBodies {
reqBody = r.serializeRequest(req)
ctx, capt = NewCapture(ctx)
}
return func(yield func(llm.StreamEvent, error) bool) {
@@ -133,7 +139,7 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S
r.recordUsage(taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status)
// Heavy trace row — only when body recording is on.
if recordBodies {
r.record(req, session, taskID, worker, reqBody, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, err)
r.record(req, session, taskID, worker, reqBody, capt, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, err)
}
}
@@ -180,7 +186,7 @@ func (r *Recorder) Stream(ctx context.Context, req llm.CompletionRequest) iter.S
}
// Complete implements the atomic completion path while preserving the same
// usage metering and optional body recording semantics as Stream.
// usage metering, normalized body recording and raw wire capture as Stream.
func (r *Recorder) Complete(ctx context.Context, req llm.CompletionRequest) (llm.Message, string, llm.Usage, error) {
recordBodies := r.enabled == nil || r.enabled()
start := time.Now()
@@ -193,8 +199,10 @@ func (r *Recorder) Complete(ctx context.Context, req llm.CompletionRequest) (llm
}
reqBody := ""
var capt *Capture
if recordBodies {
reqBody = r.serializeRequest(req)
ctx, capt = NewCapture(ctx)
}
msg, stopReason, usage, err := r.inner.Complete(ctx, req)
@@ -204,20 +212,21 @@ func (r *Recorder) Complete(ctx context.Context, req llm.CompletionRequest) (llm
}
r.recordUsage(taskID, expID, worker, usage, int(time.Since(start).Milliseconds()), status)
if recordBodies {
var textBuf, thinkingBuf strings.Builder
for _, block := range msg.Content {
switch block.Type {
case llm.BlockText:
textBuf.WriteString(block.Text)
case llm.BlockThinking:
thinkingBuf.WriteString(block.Thinking)
}
}
r.record(req, session, taskID, worker, reqBody, start, textBuf.String(), thinkingBuf.String(), usage, stopReason, err)
r.record(req, session, taskID, worker, reqBody, capt, start, msg.Text(), thinkingText(msg), usage, stopReason, err)
}
return msg, stopReason, usage, err
}
func thinkingText(msg llm.Message) string {
var b strings.Builder
for _, block := range msg.Content {
if block.Type == llm.BlockThinking && block.Thinking != "" {
b.WriteString(block.Thinking)
}
}
return b.String()
}
// recordUsage appends one lightweight metering row to llm_usage (no bodies). Skips
// zero-token calls with no model, which carry nothing worth metering.
func (r *Recorder) recordUsage(taskID string, expID int64, worker string, usage llm.Usage, latencyMs int, status string) {
@@ -249,7 +258,7 @@ func (r *Recorder) recordUsage(taskID string, expID int64, worker string, usage
// record persists one LLM call to PostgreSQL before the provider stream returns.
// Keeping the write inside the owning task operation means task deletion can
// drain calls and then remove records without a late async insert recreating one.
func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, reqBody string, start time.Time, text, thinking string, usage llm.Usage, stopReason string, streamErr error) {
func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, reqBody string, capt *Capture, start time.Time, text, thinking string, usage llm.Usage, stopReason string, streamErr error) {
latency := int(time.Since(start).Milliseconds())
status := "ok"
errMsg := ""
@@ -295,6 +304,11 @@ func (r *Recorder) record(req llm.CompletionRequest, session, taskID, worker, re
Error: errMsg,
RequestBody: reqBody,
ResponseBody: string(respBody),
// Raw wire bodies; empty when the transport did not fill the Capture
// (e.g. a provider dialing through a client without the capture hook, or
// a call that failed before any HTTP request went out).
RawRequest: capt.RawRequest(),
RawResponse: capt.RawResponse(),
}
if err := r.pg.InsertLLMRecord(rec); err != nil {
+235
View File
@@ -0,0 +1,235 @@
#!/usr/bin/env bash
# =============================================================================
# ARTEX 管理员密码重置脚本
#
# 登录用户名固定为 ARTEX;密码以 bcrypt 哈希存放在数据库 settings 表的
# auth.password_hash 键。本脚本连上数据库后,用 pgcrypto 在库内生成 bcrypt 哈希
# 并写回该键——与后端登录校验(golang.org/x/crypto/bcrypt)完全兼容。
#
# 两种部署:
# local (默认)—— 宿主机直接用 psql 连数据库。连接信息按以下优先级获取:
# 命令行参数 > --dsn/$ARTEX_PG_DSN > config.json 的 database.*
# docker —— 通过 `docker compose exec`(或 `docker exec`)在 postgres
# 容器内执行 psql(compose 默认不对宿主暴露 5432,故走容器内)。
#
# 用法示例:
# ./reset-password.sh # 本地,自动读 config.json/环境,交互输入新密码
# ./reset-password.sh -p 'NewPass!' # 本地,直接给定新密码
# ./reset-password.sh --dsn postgres://u:p@h:5432/artex
# ./reset-password.sh -H 127.0.0.1 -P 5433 -U autopentest -W pass -d artex
# ./reset-password.sh -m docker # docker 部署(读 .env 的 POSTGRES_*)
# ./reset-password.sh -m docker -c pg容器名 --exec docker
#
# 安全:新密码经环境变量 + psql \getenv 传入(不进入进程 argv),并用 :'var'
# 自动转义(防 SQL 注入);数据库密码经 PGPASSWORD 传递,同样不进 argv。
# =============================================================================
set -euo pipefail
PASS_KEY="auth.password_hash"
BCRYPT_COST=10
MODE="" # local | docker(空=自动判定)
DSN=""
HOST="" PORT="" USER="" DBPASS="" DBNAME="" SSLMODE=""
CONFIG=""
CONTAINER="" # docker 模式的 postgres 服务/容器名(默认 postgres)
EXEC_KIND="" # compose | docker(docker 模式下用哪种 exec;空=自动)
NEWPASS=""
ASSUME_YES=0
die() { echo "错误:$*" >&2; exit 1; }
info() { echo "· $*" >&2; }
usage() { sed -n '2,40p' "$0" | sed 's/^# \{0,1\}//'; exit 0; }
# ---- 参数解析 -------------------------------------------------------------
while [[ $# -gt 0 ]]; do
case "$1" in
-m|--mode) MODE="${2:-}"; shift 2 ;;
--dsn) DSN="${2:-}"; shift 2 ;;
-H|--host) HOST="${2:-}"; shift 2 ;;
-P|--port) PORT="${2:-}"; shift 2 ;;
-U|--user) USER="${2:-}"; shift 2 ;;
-W|--db-password) DBPASS="${2:-}"; shift 2 ;;
-d|--dbname) DBNAME="${2:-}"; shift 2 ;;
--sslmode) SSLMODE="${2:-}"; shift 2 ;;
--config) CONFIG="${2:-}"; shift 2 ;;
-c|--container) CONTAINER="${2:-}"; shift 2 ;;
--exec) EXEC_KIND="${2:-}"; shift 2 ;;
-p|--new-password) NEWPASS="${2:-}"; shift 2 ;;
-y|--yes) ASSUME_YES=1; shift ;;
-h|--help) usage ;;
*) die "未知参数:$1(-h 查看用法)" ;;
esac
done
# ---- 从 config.json 读取 database.*(仅 local 模式、且未显式给出连接时)-----
# 优先用 python3 解析(健壮);缺 python3 时退回 grep(config.json 为规整分字段)。
read_config_json() {
local path="$1"
[[ -f "$path" ]] || return 1
if command -v python3 >/dev/null 2>&1; then
python3 - "$path" <<'PY'
import json, sys
try:
d = json.load(open(sys.argv[1])).get("database", {})
except Exception:
sys.exit(1)
# 支持直接给 dsn,或分字段
if d.get("dsn"):
print("DSN\t" + d["dsn"]); sys.exit(0)
for k in ("host","port","user","password","dbname","sslmode"):
if d.get(k) is not None:
print(k.upper() + "\t" + str(d[k]))
PY
else
# 极简后备:逐键 grep(值为字符串或数字)
local k
for k in host port user password dbname sslmode; do
local v
v=$(grep -oE "\"$k\"[[:space:]]*:[[:space:]]*(\"[^\"]*\"|[0-9]+)" "$path" 2>/dev/null \
| head -1 | sed -E "s/.*:[[:space:]]*//; s/^\"//; s/\"$//") || true
[[ -n "$v" ]] && echo -e "${k^^}\t$v"
done
fi
}
apply_config_fields() {
local line key val
while IFS=$'\t' read -r key val; do
[[ -z "$key" ]] && continue
case "$key" in
DSN) [[ -z "$DSN" ]] && DSN="$val" ;;
HOST) [[ -z "$HOST" ]] && HOST="$val" ;;
PORT) [[ -z "$PORT" ]] && PORT="$val" ;;
USER) [[ -z "$USER" ]] && USER="$val" ;;
PASSWORD) [[ -z "$DBPASS" ]] && DBPASS="$val" ;;
DBNAME) [[ -z "$DBNAME" ]] && DBNAME="$val" ;;
SSLMODE) [[ -z "$SSLMODE" ]] && SSLMODE="$val" ;;
esac
done
}
# ---- 自动判定模式 ---------------------------------------------------------
if [[ -z "$MODE" ]]; then
if [[ -n "$DSN$HOST$USER$DBNAME" || -n "${ARTEX_PG_DSN:-}" || -f "${CONFIG:-config.json}" ]]; then
MODE="local"
elif command -v docker >/dev/null 2>&1 && [[ -f docker-compose.yml ]]; then
MODE="docker"
else
MODE="local"
fi
fi
info "部署模式:$MODE"
# ---- 采集新密码 -----------------------------------------------------------
if [[ -z "$NEWPASS" ]]; then
read -r -s -p "输入新密码(用户名固定为 ARTEX):" NEWPASS; echo >&2
[[ -n "$NEWPASS" ]] || die "密码不能为空"
read -r -s -p "再次输入以确认:" NEWPASS2; echo >&2
[[ "$NEWPASS" == "$NEWPASS2" ]] || die "两次输入不一致"
fi
[[ -n "$NEWPASS" ]] || die "密码不能为空"
# 通过环境变量把密码交给 psql(\getenv 读取,不进入 argv/ps)
export ARTEX_RESET_NEWPASS="$NEWPASS"
# 库内生成 bcrypt 并 upsert;密码用 :'newpw' 自动转义。CREATE EXTENSION 幂等,
# 若数据库角色无建扩展权限会在此报错(提示见下方 run 的失败分支)。
SQL=$(cat <<SQL
\\set ON_ERROR_STOP on
\\getenv newpw ARTEX_RESET_NEWPASS
CREATE EXTENSION IF NOT EXISTS pgcrypto;
INSERT INTO settings(key, value)
VALUES ('$PASS_KEY', crypt(:'newpw', gen_salt('bf', $BCRYPT_COST)))
ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = now();
SQL
)
# ---- 执行 -----------------------------------------------------------------
if [[ "$MODE" == "local" ]]; then
# 连接信息优先级:命令行 > --dsn/$ARTEX_PG_DSN > config.json
if [[ -z "$DSN" && -z "$HOST$USER$DBNAME" ]]; then
[[ -n "${ARTEX_PG_DSN:-}" ]] && DSN="$ARTEX_PG_DSN"
fi
if [[ -z "$DSN" && -z "$HOST$USER$DBNAME" ]]; then
cfg="${CONFIG:-config.json}"
if [[ -f "$cfg" ]]; then
info "从 $cfg 读取数据库配置"
apply_config_fields < <(read_config_json "$cfg")
fi
fi
command -v psql >/dev/null 2>&1 || die "本机未找到 psql(请安装 postgresql-client,或改用 -m docker)"
declare -a PSQL_ARGS=()
if [[ -n "$DSN" ]]; then
PSQL_ARGS=("$DSN")
target="$DSN"
else
[[ -n "$USER" ]] || die "缺少数据库用户(-U)或有效的 config.json/DSN"
[[ -n "$DBNAME" ]] || die "缺少数据库名(-d)或有效的 config.json/DSN"
HOST="${HOST:-127.0.0.1}"; PORT="${PORT:-5432}"; SSLMODE="${SSLMODE:-disable}"
PSQL_ARGS=(-h "$HOST" -p "$PORT" -U "$USER" -d "$DBNAME")
[[ -n "$SSLMODE" ]] && export PGSSLMODE="$SSLMODE"
[[ -n "$DBPASS" ]] && export PGPASSWORD="$DBPASS"
target="$USER@$HOST:$PORT/$DBNAME"
fi
info "目标数据库:$target"
if [[ "$ASSUME_YES" -ne 1 ]]; then
read -r -p "确认在该库重置 ARTEX 密码?[y/N] " ans
[[ "$ans" == "y" || "$ans" == "Y" ]] || die "已取消"
fi
if ! printf '%s\n' "$SQL" | psql "${PSQL_ARGS[@]}" -v ON_ERROR_STOP=1 -q >/dev/null; then
die "写入失败。若报 pgcrypto 权限/缺失,请用具备建扩展权限的角色,或先手动执行 CREATE EXTENSION pgcrypto。"
fi
else
# ---- docker ----
command -v docker >/dev/null 2>&1 || die "未找到 docker"
CONTAINER="${CONTAINER:-postgres}"
# 选择 exec 方式:优先 docker compose exec(服务名),否则 docker exec(容器名)
if [[ -z "$EXEC_KIND" ]]; then
if docker compose version >/dev/null 2>&1 && [[ -f docker-compose.yml ]]; then
EXEC_KIND="compose"
else
EXEC_KIND="docker"
fi
fi
# 容器内的 psql 凭据:优先命令行,其次 .env 的 POSTGRES_*,再退回 compose 默认(artex)
if [[ -f .env ]]; then
# shellcheck disable=SC1091
set -a; . ./.env; set +a
fi
DUSER="${USER:-${POSTGRES_USER:-artex}}"
DNAME="${DBNAME:-${POSTGRES_DB:-artex}}"
[[ -n "$DBPASS" ]] && export PGPASSWORD="$DBPASS"
[[ -z "${PGPASSWORD:-}" && -n "${POSTGRES_PASSWORD:-}" ]] && export PGPASSWORD="$POSTGRES_PASSWORD"
info "目标:容器 $CONTAINER 内 psql -U $DUSER -d $DNAME(exec=$EXEC_KIND)"
if [[ "$ASSUME_YES" -ne 1 ]]; then
read -r -p "确认在该容器数据库重置 ARTEX 密码?[y/N] " ans
[[ "$ans" == "y" || "$ans" == "Y" ]] || die "已取消"
fi
# -e 只带名字不带值 → 从当前环境继承,密码不出现在 docker 命令 argv 里。
declare -a EXEC_CMD
if [[ "$EXEC_KIND" == "compose" ]]; then
EXEC_CMD=(docker compose exec -T -e ARTEX_RESET_NEWPASS -e PGPASSWORD "$CONTAINER"
psql -U "$DUSER" -d "$DNAME" -v ON_ERROR_STOP=1 -q)
else
EXEC_CMD=(docker exec -i -e ARTEX_RESET_NEWPASS -e PGPASSWORD "$CONTAINER"
psql -U "$DUSER" -d "$DNAME" -v ON_ERROR_STOP=1 -q)
fi
if ! printf '%s\n' "$SQL" | "${EXEC_CMD[@]}" >/dev/null; then
die "写入失败。请确认容器名(-c)、数据库账号(.env 的 POSTGRES_*),以及角色有 pgcrypto 权限。"
fi
fi
unset ARTEX_RESET_NEWPASS
echo "✓ 已重置 ARTEX 管理员密码。请用用户名 ARTEX + 新密码登录(无需重启服务)。"
+133
View File
@@ -0,0 +1,133 @@
package server
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/Autumn-27/artex/agent"
"github.com/Autumn-27/artex/llmrec"
"github.com/Autumn-27/norma/llm"
"github.com/Autumn-27/norma/transcript"
)
// Full chain against a real PG: Recorder → norma provider → capturing transport
// → llm_records. The unit tests cover each hop; this proves the raw bodies
// actually survive all the way into the row an operator opens when debugging.
func TestRecorderPersistsRawWireBodies(t *testing.T) {
m, err := NewManager(t.TempDir(), "")
if err != nil {
t.Skipf("postgres unavailable (%v) — skipping", err)
}
defer m.Close()
pg := m.PG()
if err := pg.EnsureLLMRecordsTable(); err != nil {
t.Fatalf("ensure table: %v", err)
}
sse := strings.Join([]string{
`event: message_start`,
`data: {"type":"message_start","message":{"id":"msg_raw","usage":{"input_tokens":5,"output_tokens":1}}}`,
``,
`event: content_block_delta`,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"probing"}}`,
``,
// A tool_use block: the normalized response body drops these entirely, so
// its presence in raw_response is the whole point of the feature.
`event: content_block_start`,
`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_raw","name":"bash"}}`,
``,
`event: content_block_delta`,
`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\"command\":\"id\"}"}}`,
``,
`event: message_delta`,
`data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":9}}`,
``,
`event: message_stop`,
`data: {"type":"message_stop"}`,
``,
}, "\n")
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("content-type", "text/event-stream")
_, _ = io.WriteString(w, sse)
}))
defer srv.Close()
cfg := agent.Config{Format: llm.FormatAnthropic, BaseURL: srv.URL, APIKey: "k", Model: "claude-raw-test"}
inner, err := cfg.NewProvider()
if err != nil {
t.Fatalf("NewProvider: %v", err)
}
const session = "exp999999-rawtest"
rec := llmrec.Wrap(inner, pg, cfg.Model, "raw-test-profile", "", "", func() bool { return true })
// Clean up whatever this test writes, whether or not it passes. Must be a
// defer registered after `defer m.Close()` (LIFO puts it first) — a
// t.Cleanup would run after the manager already closed the pool.
defer func() {
if _, err := pg.Exec(`DELETE FROM llm_records WHERE session_id = $1`, session); err != nil {
t.Logf("cleanup: %v", err)
}
}()
ctx := transcript.WithSessionID(context.Background(), session)
for _, err := range rec.Stream(ctx, llm.CompletionRequest{
System: []string{"system prompt"},
Messages: []llm.Message{{Role: "user", Content: []llm.ContentBlock{{Type: "text", Text: "go"}}}},
Tools: []llm.ToolSchema{{
Name: "bash",
Description: "run a shell command",
InputSchema: map[string]any{"type": "object"},
}},
MaxTokens: 512,
}) {
if err != nil {
t.Fatalf("stream: %v", err)
}
}
var id int64
if err := pg.QueryRow(`SELECT id FROM llm_records WHERE session_id=$1 ORDER BY id DESC LIMIT 1`, session).Scan(&id); err != nil {
t.Fatalf("no record written: %v", err)
}
got, err := pg.GetLLMRecord(id)
if err != nil {
t.Fatalf("GetLLMRecord: %v", err)
}
if got.RawResponse != sse {
t.Errorf("raw_response is not the wire bytes:\n got: %q\nwant: %q", got.RawResponse, sse)
}
// The tool call is absent from the normalized body but present in the raw one
// — the specific gap this feature closes.
if strings.Contains(got.ResponseBody, "toolu_raw") {
t.Error("normalized body unexpectedly contains the tool_use block; test no longer proves the gap")
}
if !strings.Contains(got.RawResponse, "toolu_raw") {
t.Error("raw_response lost the tool_use block")
}
var body map[string]any
if err := json.Unmarshal([]byte(got.RawRequest), &body); err != nil {
t.Fatalf("raw_request is not valid JSON: %v\n%s", err, got.RawRequest)
}
if body["model"] != "claude-raw-test" {
t.Errorf("raw_request model=%v", body["model"])
}
tools, _ := body["tools"].([]any)
if len(tools) != 1 {
t.Fatalf("raw_request tools=%v", body["tools"])
}
if tool, _ := tools[0].(map[string]any); tool["input_schema"] == nil {
t.Errorf("raw_request lost the tool schema: %v", tool)
}
// The normalized request keeps tool names only, so the schema is unique to raw.
if strings.Contains(got.RequestBody, "input_schema") {
t.Error("normalized request unexpectedly carries schemas; test no longer proves the gap")
}
}
+60
View File
@@ -573,6 +573,66 @@ func (s *Server) reseedMainAgentPrompt() {
log.Printf("[prompts] mainagent 提示词已追加新默认版本(加入目标达成后反问建目标,一次性)")
}
// reseedPlannerPrompt 把 planner 提示词刷成【当前代码默认】——默认正文重写了「0 意图」的正当理由
// (从笼统"最常见"改为"仅在已覆盖/等待在跑 work 依赖时才 0 意图")、并新增「量化验收核对」(覆盖度等
// 可量化目标未达标禁止 prove_goal)。SeedPromptIfEmpty 首插入only,旧库已有版本收不到,故用版本管理
// 【追加一个新版本】并切过去(ResetPromptToDefault),旧版本仍保留在历史里,用户若自定义过可从版本记录
// 找回。settings flag 守卫 → 只做一次。全新库无需处理(SeedPromptIfEmpty 已 seed 最新默认)。与
// reseedGoalsPrompt 完全同构。
func (s *Server) reseedPlannerPrompt() {
const flag = "planner_prompt_zerointent_acceptance_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次
a, err := s.m.pg.GetAgentByKey("planner")
if err != nil || a == nil {
return // 全新库尚未建 agent 行时,seedPrompts 会直接 seed 最新默认,无需此迁移
}
tmpl := agent.BuiltinPromptSeeds()["planner"]
if tmpl == "" {
return
}
// 全新库 seedPrompts 已 seed 最新默认 → 当前版本已等于代码默认,不必再追加重复版本。
if cur, err := s.m.pg.CurrentPrompt(a.ID); err == nil && cur == tmpl {
return
}
if _, err := s.m.pg.ResetPromptToDefault(a.ID, tmpl); err != nil {
log.Printf("[prompts] planner 提示词重刷为新默认失败: %v", err)
return
}
log.Printf("[prompts] planner 提示词已追加新默认版本(重写0意图理由+加量化验收核对,一次性)")
}
// reseedWorkerPrompt 把 worker 提示词刷成【当前代码默认】——默认正文强化了「否定结论的证据门槛」
// (未穷尽手段/证据弱一律标 inferred,别用轻率 observed 否定焊死路线)。SeedPromptIfEmpty 首插入only,
// 旧库已有版本收不到,故用版本管理【追加一个新版本】并切过去,旧版本仍保留在历史里可找回。settings flag
// 守卫 → 只做一次。全新库无需处理。与 reseedGoalsPrompt 完全同构。
func (s *Server) reseedWorkerPrompt() {
const flag = "worker_prompt_negfact_threshold_v1"
if v, _, _ := s.m.pg.GetSetting(flag); v == "true" {
return
}
defer func() { _ = s.m.pg.SetSetting(flag, "true") }() // 无论成功与否只尝试一次
a, err := s.m.pg.GetAgentByKey("worker")
if err != nil || a == nil {
return // 全新库尚未建 agent 行时,seedPrompts 会直接 seed 最新默认,无需此迁移
}
tmpl := agent.BuiltinPromptSeeds()["worker"]
if tmpl == "" {
return
}
// 全新库 seedPrompts 已 seed 最新默认 → 当前版本已等于代码默认,不必再追加重复版本。
if cur, err := s.m.pg.CurrentPrompt(a.ID); err == nil && cur == tmpl {
return
}
if _, err := s.m.pg.ResetPromptToDefault(a.ID, tmpl); err != nil {
log.Printf("[prompts] worker 提示词重刷为新默认失败: %v", err)
return
}
log.Printf("[prompts] worker 提示词已追加新默认版本(加否定结论证据门槛,一次性)")
}
// seedReporterAgent 预置一个「报告撰写」自定义 agent(builtin=false,可在 UI 编辑/删除):
// 绑定 update_finding_report + 任务查询工具,并挂一个「report_finding 被调用即触发」的
// 触发器 —— 每登记一个漏洞就唤起它写详细报告。一次性(settings flag 守卫):用户删掉后不再重建。
+2 -3
View File
@@ -323,10 +323,9 @@ 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() already returns one of the three valid format strings
// (anthropic / openai / openai-responses), matching the DB CHECK constraint.
format := cfg.Provider()
if format != "anthropic" {
format = "openai"
}
var id int64
// agent.Config carries no failover fields, so carry the stored ones forward —
// otherwise this legacy endpoint would silently reset the profile's priority,
+4 -2
View File
@@ -1926,11 +1926,13 @@ func (s *Server) pgListModels(w http.ResponseWriter, r *http.Request) {
var candidates []candidate
switch provider {
case "openai":
case "openai", "openai-responses":
// Responses API shares the OpenAI model list at /v1/models; tolerate a full
// endpoint URL of either format.
if baseURL == "" {
baseURL = "https://api.openai.com/v1"
}
b := strings.TrimRight(strings.TrimSuffix(baseURL, "/chat/completions"), "/")
b := strings.TrimRight(strings.TrimSuffix(strings.TrimSuffix(baseURL, "/chat/completions"), "/responses"), "/")
candidates = append(candidates, candidate{b + "/models", bearerHdr()})
default: // anthropic
if baseURL == "" {
+62 -25
View File
@@ -6,6 +6,7 @@ import {
ArrowUpIcon,
Bot,
ChevronDownIcon,
ListChecksIcon,
Loader2Icon,
MoreHorizontalIcon,
PaperclipIcon,
@@ -763,6 +764,7 @@ const ConversationItem = React.memo(function ConversationItem({
onCancelRename,
onTogglePinned,
onDelete,
selectionMode,
selectedForDelete,
onSelectedForDeleteChange,
}: {
@@ -778,6 +780,7 @@ const ConversationItem = React.memo(function ConversationItem({
onCancelRename: () => void;
onTogglePinned: (conversation: Conversation) => void;
onDelete: (id: number) => void;
selectionMode: boolean;
selectedForDelete: boolean;
onSelectedForDeleteChange: (id: number, checked: boolean) => void;
}) {
@@ -800,12 +803,14 @@ const ConversationItem = React.memo(function ConversationItem({
active ? "bg-accent text-accent-foreground" : "hover:bg-accent/50",
)}
>
<Checkbox
checked={selectedForDelete}
onCheckedChange={(checked) => onSelectedForDeleteChange(conv.id, checked === true)}
aria-label={`选择对话「${conv.title || "新对话"}」`}
className="ml-1 shrink-0"
/>
{selectionMode && (
<Checkbox
checked={selectedForDelete}
onCheckedChange={(checked) => onSelectedForDeleteChange(conv.id, checked === true)}
aria-label={`选择对话「${conv.title || "新对话"}」`}
className="ml-1 shrink-0"
/>
)}
{renaming ? (
<input
ref={renameInputRef}
@@ -907,6 +912,10 @@ export default function ChatPage() {
const [renamingId, setRenamingId] = React.useState<number | null>(null);
const [renameText, setRenameText] = React.useState("");
const [selectedConversationIds, setSelectedConversationIds] = React.useState<Set<number>>(() => new Set());
// selectionMode gates the multi-select UI: off by default (clean list, no
// checkboxes); the header "多选" button turns it on, "完成" turns it off and
// clears the selection.
const [selectionMode, setSelectionMode] = React.useState(false);
const [bulkDeleteOpen, setBulkDeleteOpen] = React.useState(false);
const [bulkDeleting, setBulkDeleting] = React.useState(false);
const [visibleConversationCount, setVisibleConversationCount] = React.useState(CONVERSATION_LIST_PAGE);
@@ -998,6 +1007,11 @@ export default function ChatPage() {
setSelectedConversationIds(checked ? new Set(convs.map((conversation) => conversation.id)) : new Set());
}
function exitSelectionMode() {
setSelectionMode(false);
setSelectedConversationIds(new Set());
}
const del = React.useCallback(
async (id: number) => {
try {
@@ -1046,6 +1060,9 @@ export default function ChatPage() {
toast.error(`${failed.length} 个对话删除失败:${details}${failed.length > 3 ? " 等" : ""}`);
}
setBulkDeleteOpen(false);
// Fully successful → return to the clean list; keep selection mode on if
// some failed so the user can retry the remaining ones.
if (failed.length === 0) setSelectionMode(false);
reloadConvs();
} catch (error) {
toast.error(`批量删除失败:${(error as Error).message}`);
@@ -1100,29 +1117,48 @@ export default function ChatPage() {
<Button size="sm" className="w-full" onClick={() => setSelectedId(null)}>
<PlusIcon /> 新建对话
</Button>
{convs.length > 0 && (
<div className="flex items-center gap-2 px-1">
<Checkbox
checked={conversationHeaderChecked}
onCheckedChange={(checked) => toggleAllConversations(checked === true)}
aria-label="选择全部对话"
/>
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
{selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : `共 ${convs.length} 个`}
</span>
{selectedConversationCount > 0 && (
{convs.length > 0 &&
(selectionMode ? (
<div className="flex items-center gap-2 px-1">
<Checkbox
checked={conversationHeaderChecked}
onCheckedChange={(checked) => toggleAllConversations(checked === true)}
aria-label="选择全部对话"
/>
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
{selectedConversationCount > 0 ? `已选 ${selectedConversationCount} 个` : `共 ${convs.length} 个`}
</span>
{selectedConversationCount > 0 && (
<Button
size="sm"
variant="destructive"
disabled={bulkDeleting}
onClick={() => setBulkDeleteOpen(true)}
>
<Trash2Icon data-icon="inline-start" />
删除
</Button>
)}
<Button size="sm" variant="ghost" onClick={exitSelectionMode}>
完成
</Button>
</div>
) : (
<div className="flex items-center gap-2 px-1">
<span className="text-muted-foreground min-w-0 flex-1 text-xs tabular-nums">
共 {convs.length} 个
</span>
<Button
size="sm"
variant="destructive"
disabled={bulkDeleting}
onClick={() => setBulkDeleteOpen(true)}
variant="ghost"
className="text-muted-foreground"
onClick={() => setSelectionMode(true)}
>
<Trash2Icon data-icon="inline-start" />
删除
<ListChecksIcon data-icon="inline-start" />
多选
</Button>
)}
</div>
)}
</div>
))}
</div>
<ScrollArea type="auto" className="min-h-0 min-w-0 flex-1 [&_[data-slot=scroll-area-viewport]>div]:block!">
<div className="flex min-w-0 flex-col gap-0.5 p-2">
@@ -1142,6 +1178,7 @@ export default function ChatPage() {
onCancelRename={cancelRename}
onTogglePinned={togglePinned}
onDelete={del}
selectionMode={selectionMode}
selectedForDelete={selectedConversationIds.has(c.id)}
onSelectedForDeleteChange={toggleConversationSelected}
/>
@@ -9,6 +9,8 @@ import {
Loader2Icon,
XIcon,
Trash2Icon,
CopyIcon,
CheckIcon,
} from "lucide-react";
import {
@@ -74,6 +76,47 @@ function tryFormatJSON(s: string): string {
}
}
// 复制当前框内文本的小按钮。复制成功后短暂显示对勾。text 为空/仅占位符时禁用。
function CopyButton({ text }: { text: string }) {
const [copied, setCopied] = React.useState(false);
const timer = React.useRef<ReturnType<typeof setTimeout> | null>(null);
React.useEffect(() => () => { if (timer.current) clearTimeout(timer.current); }, []);
const disabled = !text;
const copy = async () => {
if (disabled) return;
try {
await navigator.clipboard.writeText(text);
} catch {
// navigator.clipboard 在非安全上下文(如 http 局域网)不可用,回退到 execCommand。
const ta = document.createElement("textarea");
ta.value = text;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try { document.execCommand("copy"); } catch { /* 忽略:不支持则静默 */ }
document.body.removeChild(ta);
}
setCopied(true);
if (timer.current) clearTimeout(timer.current);
timer.current = setTimeout(() => setCopied(false), 1500);
};
return (
<Button
variant="ghost"
size="icon"
className="size-5 shrink-0"
disabled={disabled}
title={copied ? "已复制" : "复制内容"}
onClick={copy}
>
{copied ? <CheckIcon className="size-3 text-emerald-600" /> : <CopyIcon className="size-3" />}
</Button>
);
}
const PAGE_SIZES = [25, 50, 100];
export default function LLMRecordsPage() {
@@ -96,6 +139,21 @@ export default function LLMRecordsPage() {
const [selected, setSelected] = React.useState<LLMRecordItem | null>(null);
const [detail, setDetail] = React.useState<LLMRecordDetail | null>(null);
const [detailLoading, setDetailLoading] = React.useState(false);
// 归一化视图 / HTTP 原文视图。原文是排查 provider 侧问题的唯一依据:归一化视图
// 不含工具 schema,响应里也没有 tool_use 块。
const [rawView, setRawView] = React.useState(false);
const hasRaw = !!(detail?.raw_request || detail?.raw_response);
// 开关保持用户选择,但切到一条无原文的旧记录时自动落回解析视图,而不是显示空白。
const showRaw = rawView && hasRaw;
// 原文请求体是 JSON,pretty-print 只改排版不改语义,便于阅读;原文响应是 SSE
// 帧,tryFormatJSON 解析失败会原样返回,故两边共用一个函数即可。
const reqText = showRaw
? detail?.raw_request && tryFormatJSON(detail.raw_request)
: detail?.request_body && tryFormatJSON(detail.request_body);
const respText = showRaw
? detail?.raw_response
: detail?.response_body && tryFormatJSON(detail.response_body);
// Per-task delete (task picker + confirm dialog)
const [tasks, setTasks] = React.useState<LLMTask[]>([]);
@@ -427,10 +485,22 @@ export default function LLMRecordsPage() {
) : (
<Badge variant="destructive" className="text-xs">Error</Badge>
)}
{/* 原文视图开关。旧记录没有原文,此时禁用而非静默回退,避免看着像
「原文与解析一致」。 */}
<Button
variant={showRaw ? "secondary" : "ghost"}
size="sm"
className="ml-auto h-7 shrink-0 text-xs"
disabled={!hasRaw}
title={hasRaw ? "查看与 provider 实际收发的 HTTP 原文" : "该记录录制于此功能上线前,无原文"}
onClick={() => setRawView((v) => !v)}
>
原文
</Button>
<Button
variant="ghost"
size="icon"
className="ml-auto size-7 shrink-0"
className="size-7 shrink-0"
onClick={() => setSelected(null)}
>
<XIcon />
@@ -439,8 +509,9 @@ export default function LLMRecordsPage() {
{/* Request / Response split */}
<div className="grid min-h-0 flex-1 grid-cols-2 divide-x">
<div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
Request
<div className="flex items-center gap-2 border-b py-0.5 pr-1.5 pl-3 text-[11px] font-medium text-muted-foreground">
<span>Request{showRaw && " · 原文"}</span>
<CopyButton text={reqText || ""} />
</div>
<div className="min-h-0 flex-1 overflow-auto">
{detailLoading ? (
@@ -450,14 +521,15 @@ export default function LLMRecordsPage() {
</div>
) : (
<pre className="p-3 font-mono text-xs break-all whitespace-pre-wrap">
{detail?.request_body ? tryFormatJSON(detail.request_body) : "(空)"}
{reqText || "(空)"}
</pre>
)}
</div>
</div>
<div className="flex min-h-0 min-w-0 flex-col">
<div className="border-b px-3 py-1 text-[11px] font-medium text-muted-foreground">
Response
<div className="flex items-center gap-2 border-b py-0.5 pr-1.5 pl-3 text-[11px] font-medium text-muted-foreground">
<span>Response{showRaw && " · 原文(SSE)"}</span>
<CopyButton text={respText || ""} />
</div>
<div className="min-h-0 flex-1 overflow-auto">
{detailLoading ? (
@@ -470,7 +542,7 @@ export default function LLMRecordsPage() {
"p-3 font-mono text-xs break-all whitespace-pre-wrap",
selected.status !== "ok" && "text-red-600 dark:text-red-400",
)}>
{detail?.response_body ? tryFormatJSON(detail.response_body) : "(空)"}
{respText || "(空)"}
</pre>
)}
</div>
+7 -4
View File
@@ -296,7 +296,7 @@ function ProfileSheet({
}) {
const isNew = !profile;
const [name, setName] = React.useState("");
const [format, setFormat] = React.useState<"anthropic" | "openai">("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("");
@@ -321,7 +321,9 @@ function ProfileSheet({
React.useEffect(() => {
if (!open) return;
setName(profile?.name ?? "");
setFormat(profile?.format === "openai" ? "openai" : "anthropic");
setFormat(
profile?.format === "openai" || profile?.format === "openai-responses" ? profile.format : "anthropic",
);
setModel(profile?.model ?? "");
setBaseUrl(profile?.base_url ?? "");
setProxy(profile?.proxy ?? "");
@@ -462,13 +464,14 @@ function ProfileSheet({
</div>
<div className="grid gap-2">
<Label>格式</Label>
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai")}>
<Select value={format} onValueChange={(v) => setFormat(v as "anthropic" | "openai" | "openai-responses")}>
<SelectTrigger>
<SelectValue placeholder="选择格式" />
</SelectTrigger>
<SelectContent>
<SelectItem value="anthropic">Anthropic</SelectItem>
<SelectItem value="openai">OpenAI</SelectItem>
<SelectItem value="openai">OpenAI (Chat Completions)</SelectItem>
<SelectItem value="openai-responses">OpenAI (Responses API)</SelectItem>
</SelectContent>
</Select>
</div>
+36
View File
@@ -3766,5 +3766,41 @@ export function llmRecordDetail(id: number, records = llmRecords) {
null,
2,
),
// HTTP 原文:请求含被归一化视图丢弃的完整工具 schema,响应为原始 SSE 帧。
raw_request: JSON.stringify({
model: item.model,
max_tokens: 8192,
stream: true,
system: [{ type: "text", text: "你是一个授权渗透测试系统的「执行者」…(省略)", cache_control: { type: "ephemeral" } }],
messages: [{ role: "user", content: "开始执行 system 提示里的这条意图:只做它、只产生事实、做完即停。" }],
tools: [
{
name: "bash",
description: "在目标环境中执行一条 shell 命令并返回其输出。",
input_schema: { type: "object", properties: { command: { type: "string" } }, required: ["command"] },
},
],
thinking: { type: "enabled" },
}),
raw_response: [
`event: message_start`,
`data: {"type":"message_start","message":{"id":"msg_01mock","model":"${item.model}","usage":{"input_tokens":${item.input_tokens},"output_tokens":1}}}`,
``,
`event: content_block_delta`,
`data: {"type":"content_block_delta","index":0,"delta":{"type":"text_delta","text":"对 search?q= 做注入探测。"}}`,
``,
`event: content_block_start`,
`data: {"type":"content_block_start","index":1,"content_block":{"type":"tool_use","id":"toolu_01mock","name":"bash"}}`,
``,
`event: content_block_delta`,
`data: {"type":"content_block_delta","index":1,"delta":{"type":"input_json_delta","partial_json":"{\\"command\\":\\"curl -s 'https://www.acme.com/search?q=1%27'\\"}"}}`,
``,
`event: message_delta`,
`data: {"type":"message_delta","delta":{"stop_reason":"tool_use"},"usage":{"output_tokens":${item.output_tokens}}}`,
``,
`event: message_stop`,
`data: {"type":"message_stop"}`,
``,
].join("\n"),
};
}
+6 -1
View File
@@ -852,7 +852,7 @@ export interface Settings {
export interface LLMProfile {
id: string;
name: string;
format: "openai" | "anthropic";
format: "openai" | "anthropic" | "openai-responses";
base_url?: string;
proxy?: string;
model: string;
@@ -1163,6 +1163,11 @@ export interface LLMRecordItem {
export interface LLMRecordDetail extends LLMRecordItem {
request_body: string;
response_body: string;
// provider 实际收发的 HTTP 原文:请求为 buildBody() 发出的完整 body(含工具
// schema),响应为原始 SSE 帧。上面的 request_body/response_body 是归一化视图,
// 丢弃了工具 schema 与 tool_use 块。旧记录为空。
raw_request?: string;
raw_response?: string;
}
// One distinct task with its LLM-record count (task picker on the records page).