mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Compare commits
165
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
58c43fdf6c | ||
|
|
941b66d0fc | ||
|
|
008d5c5e35 | ||
|
|
18abe2b8b0 | ||
|
|
e43caacac5 | ||
|
|
b3056b2326 | ||
|
|
4f2a495578 | ||
|
|
d3e45aff25 | ||
|
|
0fe09f3a1d | ||
|
|
3d18fee663 | ||
|
|
ee9e753baf | ||
|
|
da3359c59b | ||
|
|
d7f0055d5e | ||
|
|
f5a010780c | ||
|
|
fbfdf8bdbe | ||
|
|
b238636ce4 | ||
|
|
a6b496de58 | ||
|
|
9edb081dab | ||
|
|
95ec1dd78b | ||
|
|
421dd778cd | ||
|
|
0c199e0c25 | ||
|
|
878baaae07 | ||
|
|
373c1e773c | ||
|
|
9bd7e7f104 | ||
|
|
824975d344 | ||
|
|
04c74845ea | ||
|
|
65192c35a5 | ||
|
|
c39c9e2155 | ||
|
|
8a36ceab41 | ||
|
|
b9b482df5e | ||
|
|
d2f075edeb | ||
|
|
3336055f25 | ||
|
|
65d5a9b8ed | ||
|
|
5e45f2481a | ||
|
|
c8bf792009 | ||
|
|
3fd20922ce | ||
|
|
879debe477 | ||
|
|
bdf9629c4b | ||
|
|
23fb0e3b3d | ||
|
|
fc0ddd5082 | ||
|
|
8d5b0e79f5 | ||
|
|
786395c23e | ||
|
|
8d83e5322f | ||
|
|
b610ed563d | ||
|
|
c671e7aee7 | ||
|
|
cf0b55cbf6 | ||
|
|
3536abdb11 | ||
|
|
8d76564db6 | ||
|
|
86b7f399ba | ||
|
|
28e8874cb4 | ||
|
|
723f0fabcf | ||
|
|
81742a513c | ||
|
|
369fbf83b4 | ||
|
|
465882cad6 | ||
|
|
ece5895330 | ||
|
|
1be3ce4c17 | ||
|
|
6891c67dc7 | ||
|
|
6bcb1b3f6a | ||
|
|
cfb6e5436d | ||
|
|
c9d5f416e6 | ||
|
|
41a6830338 | ||
|
|
a8d4af21e8 | ||
|
|
9d1e1387fa | ||
|
|
86acd819a4 | ||
|
|
457f44d28b | ||
|
|
63599d05ec | ||
|
|
5df01bceb6 | ||
|
|
20e70f1e49 | ||
|
|
8deaa510f0 | ||
|
|
9911440dba | ||
|
|
30b102ea55 | ||
|
|
236520bc6d | ||
|
|
0bbc7aa3fe | ||
|
|
143c407f8c | ||
|
|
43b70aeab4 | ||
|
|
ba5ae25730 | ||
|
|
4d752dbe46 | ||
|
|
d546028843 | ||
|
|
e89f4b86c3 | ||
|
|
f133f0354b | ||
|
|
b6fdbb89dd | ||
|
|
856f2fd3f9 | ||
|
|
b1ca562c42 | ||
|
|
31afb4a8e6 | ||
|
|
4ad336d7e7 | ||
|
|
349281ede0 | ||
|
|
4974aa9755 | ||
|
|
aab5763204 | ||
|
|
59996652db | ||
|
|
b44514d8b1 | ||
|
|
d7ce590b16 | ||
|
|
92f8237356 | ||
|
|
20c01d470a | ||
|
|
ed488806dc | ||
|
|
2ca2a9b53c | ||
|
|
8372c30ed1 | ||
|
|
dbf09924f9 | ||
|
|
a563a06e24 | ||
|
|
e96af1ff8d | ||
|
|
201424f7f7 | ||
|
|
f8223c51b4 | ||
|
|
0290335f25 | ||
|
|
c438c5532c | ||
|
|
cd355b6e4a | ||
|
|
0485537024 | ||
|
|
416dd550d2 | ||
|
|
ac625731ba | ||
|
|
ea10a83fd0 | ||
|
|
2c5ed024f5 | ||
|
|
61337d9940 | ||
|
|
d83661f9b0 | ||
|
|
b7eba48e91 | ||
|
|
d97c576e03 | ||
|
|
ddc48fd74b | ||
|
|
35ba5eb42d | ||
|
|
d8dbd38070 | ||
|
|
1d1d8730ff | ||
|
|
38e7d7a628 | ||
|
|
adb00dd7fa | ||
|
|
a20d34351f | ||
|
|
2e8634d735 | ||
|
|
b58734ee4e | ||
|
|
87823024e6 | ||
|
|
557a387518 | ||
|
|
0437ca8982 | ||
|
|
8feb1adb9b | ||
|
|
2a3d9b7d61 | ||
|
|
2b191c3dc5 | ||
|
|
28ccd2db41 | ||
|
|
f87b34184a | ||
|
|
e598d482e7 | ||
|
|
38632c72d7 | ||
|
|
16936dc139 | ||
|
|
c2ebf7bd56 | ||
|
|
efb2418dfa | ||
|
|
d0d73fcc0f | ||
|
|
b47f0e45d2 | ||
|
|
1cd7705536 | ||
|
|
95888d14fd | ||
|
|
e506bf7de2 | ||
|
|
091995c737 | ||
|
|
4234059fa6 | ||
|
|
d2464b59c4 | ||
|
|
bbda70c670 | ||
|
|
e4f6eb117b | ||
|
|
ad37cd5302 | ||
|
|
bcddb2963b | ||
|
|
0e41cd7947 | ||
|
|
02cd4622d9 | ||
|
|
29bca265b3 | ||
|
|
b1c2bc6756 | ||
|
|
361e56c298 | ||
|
|
25c2b4ec91 | ||
|
|
c05f72c582 | ||
|
|
0392ffed05 | ||
|
|
c442b018c4 | ||
|
|
e9da355450 | ||
|
|
acee6497d3 | ||
|
|
abac8621e9 | ||
|
|
a39b4b1cac | ||
|
|
6a3cd375cb | ||
|
|
dffb4787e6 | ||
|
|
9862aa5f84 | ||
|
|
281caed006 | ||
|
|
1023ea7bcb |
No files matched your search
@@ -15,7 +15,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: ci-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
# Cheap path gate for pull requests. PRs confined to docs/release-notes
|
||||
|
||||
@@ -16,7 +16,7 @@ permissions:
|
||||
|
||||
concurrency:
|
||||
group: studio-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
cancel-in-progress: ${{ github.event_name == 'pull_request' }}
|
||||
|
||||
jobs:
|
||||
frontend:
|
||||
@@ -141,9 +141,14 @@ jobs:
|
||||
node perf/stickyfold.mjs
|
||||
node perf/crumb.mjs
|
||||
node perf/hoverradius.mjs
|
||||
node perf/jump-ends.mjs
|
||||
node perf/modelrows.mjs
|
||||
node perf/explorersize.mjs
|
||||
node perf/rowhit.mjs
|
||||
node perf/tab-pip.mjs
|
||||
node perf/plan-chip.mjs
|
||||
node perf/flow-feather.mjs
|
||||
node perf/context-card-hover.mjs
|
||||
node perf/sessquick.mjs
|
||||
node perf/sessionrows.mjs
|
||||
node perf/wheel.mjs
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// runBlockedBatch runs one dry index batch where every arm's runOne fails
|
||||
// before any model turn: the task's own directory is a regular file, so the
|
||||
// fixture cannot be built. In production the same early return happens when the
|
||||
// fixture build, the session load, or the answer-on-disk guard fails.
|
||||
func runBlockedBatch(t *testing.T) string {
|
||||
t.Helper()
|
||||
tk := indexTasks()[0]
|
||||
root := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(root, tk.ID), []byte("not a directory\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if code := runExperiment("run-index", experimentIndex, root, true, []contextTask{tk}); code != 0 {
|
||||
t.Fatalf("runExperiment exit = %d, want 0 (every run failed, but the batch finished)", code)
|
||||
}
|
||||
return root
|
||||
}
|
||||
|
||||
func readResultLines(t *testing.T, root string) ([]contextMetrics, []string) {
|
||||
t.Helper()
|
||||
raw, err := os.ReadFile(filepath.Join(root, "results.jsonl"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var runs []contextMetrics
|
||||
var lines []string
|
||||
for text := range strings.SplitSeq(strings.TrimSpace(string(raw)), "\n") {
|
||||
var m contextMetrics
|
||||
if err := json.Unmarshal([]byte(text), &m); err != nil {
|
||||
t.Fatalf("results.jsonl: %v", err)
|
||||
}
|
||||
runs = append(runs, m)
|
||||
lines = append(lines, text)
|
||||
}
|
||||
return runs, lines
|
||||
}
|
||||
|
||||
// A run that failed before the model turn produced no measurement. Recording it
|
||||
// as a zero-valued contextMetrics puts it in the funnel's Scored denominator,
|
||||
// in "Where it broke", and in the dose curve as a real zero.
|
||||
func TestBatchDoesNotRecordAFailedRunAsAResult(t *testing.T) {
|
||||
root := runBlockedBatch(t)
|
||||
runs, lines := readResultLines(t, root)
|
||||
if len(runs) == 0 {
|
||||
t.Fatal("no runs recorded at all")
|
||||
}
|
||||
for i, m := range runs {
|
||||
if m.Task == "" || m.Arm == "" || m.FailureStage == "" {
|
||||
t.Fatalf("results.jsonl line %d records a run that never ran as a result "+
|
||||
"(task=%q arm=%q failure_stage=%q):\n %s", i+1, m.Task, m.Arm, m.FailureStage, lines[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The manifest is the artefact that says which runs counted, so a failed run
|
||||
// must not be added to CompletedRuns or to the task id list.
|
||||
func TestBatchManifestDoesNotReportFailedRunsAsCompleted(t *testing.T) {
|
||||
root := runBlockedBatch(t)
|
||||
var manifest batchManifest
|
||||
data, err := os.ReadFile(filepath.Join(root, "batch.json"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, id := range manifest.TaskIDs {
|
||||
if id == "" {
|
||||
t.Fatalf("manifest lists an empty task id: task_ids=%q (completed=%d planned=%d)",
|
||||
manifest.TaskIDs, manifest.CompletedRuns, manifest.PlannedRuns)
|
||||
}
|
||||
}
|
||||
if manifest.CompletedRuns == manifest.PlannedRuns {
|
||||
t.Fatalf("manifest reports every planned run completed although none produced evidence: "+
|
||||
"completed=%d planned=%d task_ids=%q", manifest.CompletedRuns, manifest.PlannedRuns, manifest.TaskIDs)
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,10 @@ type batchManifest struct {
|
||||
Arms []string `json:"arms"`
|
||||
PlannedRuns int `json:"planned_runs"`
|
||||
CompletedRuns int `json:"completed_runs"`
|
||||
// FailedRuns counts the planned runs that ended before a transcript existed.
|
||||
// They are rows in results.jsonl, never part of CompletedRuns, and the field
|
||||
// is absent when there were none so an intact batch is unchanged.
|
||||
FailedRuns int `json:"failed_runs,omitempty"`
|
||||
// Usage totals are absent when nothing reported any: an unmeasured cost must
|
||||
// not read as a measured zero. UsageReportedRuns stays, because zero there
|
||||
// is a fact about the batch.
|
||||
@@ -98,9 +102,15 @@ func sortedKeys(set map[string]bool) []string {
|
||||
// last, so its digests are of what is already on disk.
|
||||
func writeBatchManifest(root string, info batchInfo, all []contextMetrics) error {
|
||||
ids, arms := map[string]bool{}, map[string]bool{}
|
||||
var prompt, completion, cacheHit, cacheMiss, requests, reported int
|
||||
var prompt, completion, cacheHit, cacheMiss, requests, reported, failed int
|
||||
estimated := false
|
||||
for _, m := range all {
|
||||
// A failed run has no task, arm or evidence to contribute: it is counted
|
||||
// as a failure below and is not part of what the batch measured.
|
||||
if m.Failed {
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
ids[m.Task] = true
|
||||
arms[m.Arm] = true
|
||||
if m.UsageReportedRequests > 0 {
|
||||
@@ -125,7 +135,7 @@ func writeBatchManifest(root string, info batchInfo, all []contextMetrics) error
|
||||
GitCommit: commit, GitDirty: dirty,
|
||||
Model: model, Window: fixtureWindow, Generations: fixtureGenerations,
|
||||
TaskIDs: sortedKeys(ids), Arms: sortedKeys(arms),
|
||||
PlannedRuns: info.Planned, CompletedRuns: len(all),
|
||||
PlannedRuns: info.Planned, CompletedRuns: len(all) - failed, FailedRuns: failed,
|
||||
UsageReportedRuns: reported, UsageReportedRequests: requests,
|
||||
PromptTokens: prompt, CompletionTokens: completion,
|
||||
CacheHitTokens: cacheHit, CacheMissTokens: cacheMiss, UsageEstimated: estimated,
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// boundaryRuns builds the input reportBoundaries reads: one scored run for an
|
||||
// index task's cue side, and one failed run for another index task's cue side.
|
||||
// The runs are constructed because the report is a pure function of the slice;
|
||||
// the batch path that produces them is covered by the failed-batch tests.
|
||||
func boundaryRuns(t *testing.T) (all []contextMetrics, scoredTask, failedTask string) {
|
||||
t.Helper()
|
||||
index := indexTasks()
|
||||
if len(index) < 2 {
|
||||
t.Fatalf("need at least two index tasks, have %d", len(index))
|
||||
}
|
||||
scored, failed := index[0], index[1]
|
||||
cue, noCue := boundaryPair(scored.CueTier)
|
||||
if cue == "" || noCue == "" {
|
||||
t.Fatalf("task %s has an empty boundary pair (%q, %q)", scored.ID, cue, noCue)
|
||||
}
|
||||
failedCue, _ := boundaryPair(failed.CueTier)
|
||||
if failedCue == "" {
|
||||
t.Fatalf("task %s has no cue side", failed.ID)
|
||||
}
|
||||
all = []contextMetrics{
|
||||
{Task: scored.ID, Arm: "index-" + cue, AnswerRecovered: true, SearchCalls: 1, RecallReturnedTokens: 318},
|
||||
{Task: scored.ID, Arm: "index-" + noCue, AnswerRecovered: false},
|
||||
{Task: failed.ID, Arm: "index-" + failedCue, Failed: true, FailureStage: stageNoEvidence},
|
||||
}
|
||||
return all, scored.ID, failed.ID
|
||||
}
|
||||
|
||||
// TestBoundaryReportExcludesFailedRuns: a run that never produced a transcript
|
||||
// must not appear in the cue-boundary table, which was the last reporting exit
|
||||
// that still folded it in as a zero measurement.
|
||||
func TestBoundaryReportExcludesFailedRuns(t *testing.T) {
|
||||
all, scoredTask, failedTask := boundaryRuns(t)
|
||||
out := captureStdout(t, func() { reportBoundaries(all) })
|
||||
if strings.Contains(out, failedTask) {
|
||||
t.Fatalf("the boundary table printed the failed task %q as a measurement:\n%s", failedTask, out)
|
||||
}
|
||||
if !strings.Contains(out, scoredTask) {
|
||||
t.Fatalf("the boundary table dropped the scored task %q, so the exclusion is not specific to failures:\n%s", scoredTask, out)
|
||||
}
|
||||
if !strings.Contains(out, "cue-present") || !strings.Contains(out, "cue-absent") {
|
||||
t.Fatalf("the pooled comparison disappeared:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// TestBoundaryReportSaysNothingToCompareWhenEveryRunFailed: with no scored run
|
||||
// the report must say so, rather than printing a comparison of zeros.
|
||||
func TestBoundaryReportSaysNothingToCompareWhenEveryRunFailed(t *testing.T) {
|
||||
all, _, _ := boundaryRuns(t)
|
||||
failedOnly := make([]contextMetrics, 0, len(all))
|
||||
for _, m := range all {
|
||||
m.Failed = true
|
||||
m.FailureStage = stageNoEvidence
|
||||
failedOnly = append(failedOnly, m)
|
||||
}
|
||||
out := captureStdout(t, func() { reportBoundaries(failedOnly) })
|
||||
if !strings.Contains(out, "scored=0") || !strings.Contains(out, "failed=") {
|
||||
t.Fatalf("an all-failed batch did not report scored=0 with a failed count:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "cue-present") || strings.Contains(out, "cue-absent") {
|
||||
t.Fatalf("an all-failed batch still printed a pooled comparison of zeros:\n%s", out)
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,8 @@ import (
|
||||
// canonical position, and never the model's prose. "I will search the history"
|
||||
// is not a search; a recall call with a query is.
|
||||
|
||||
// failure stages, in funnel order. Exactly one is reported per run.
|
||||
// failure stages, in funnel order. Exactly one is reported per run, except for
|
||||
// NoEvidence: that stage belongs to a run with no measurement at all.
|
||||
const (
|
||||
stageReadMissed = "NoSearch" // never asked, and never read the target either
|
||||
stageSearchMiss = "SearchMiss" // searched, target never came back
|
||||
@@ -26,6 +27,7 @@ const (
|
||||
stageAnswerWrong = "ReadButAnswerWrong" // opened it and still answered wrong
|
||||
stageRecovered = "Recovered"
|
||||
stageContaminated = "Contaminated"
|
||||
stageNoEvidence = "NoEvidence"
|
||||
|
||||
// Routing is judged by model round, not by the linear order of tool events:
|
||||
// two calls in one round are one parallel fan-out, and calling that
|
||||
@@ -116,6 +118,12 @@ type contextMetrics struct {
|
||||
FailureStage string `json:"failure_stage"`
|
||||
TimeoutReason string `json:"timeout_reason,omitempty"`
|
||||
|
||||
// Failed marks a run that ended before a transcript existed: it has no score
|
||||
// and no place in the statistics. The row stays in results.jsonl with its
|
||||
// task, arm and stage, so a failure is counted rather than dropped.
|
||||
Failed bool `json:"failed,omitempty"`
|
||||
Reason string `json:"reason,omitempty"`
|
||||
|
||||
// UnexpectedWorkTools are investigation tools used instead of recall.
|
||||
// Reaching for the workspace is behaviour, not noise: how a model routes
|
||||
// its uncertainty when memory is missing is part of what is measured.
|
||||
@@ -462,6 +470,12 @@ type funnel struct {
|
||||
func summarize(arm string, runs []contextMetrics) funnel {
|
||||
f := funnel{Arm: arm, Runs: len(runs), Stages: map[string]int{}, Routes: map[string]int{}}
|
||||
for _, r := range runs {
|
||||
// A run that never produced a transcript is not a scored run and has no
|
||||
// stage to place: counting it would put an empty name in the funnel, the
|
||||
// stage table and the routes.
|
||||
if r.Failed {
|
||||
continue
|
||||
}
|
||||
if r.Contaminated {
|
||||
f.Contaminated++
|
||||
f.Stages[r.FailureStage]++
|
||||
@@ -631,7 +645,7 @@ func reportStopping(all []contextMetrics) string {
|
||||
var extraRounds, extraTokens []float64
|
||||
sufficient, answeredNext := 0, 0
|
||||
for _, m := range all {
|
||||
if m.Contaminated {
|
||||
if m.Failed || m.Contaminated {
|
||||
continue
|
||||
}
|
||||
classes[m.StoppingClass]++
|
||||
|
||||
@@ -121,7 +121,7 @@ var doseMetrics = []struct {
|
||||
func reportDoseResponse(all []contextMetrics) string {
|
||||
valid := map[string]map[string]contextMetrics{}
|
||||
for _, m := range all {
|
||||
if m.Contaminated || m.FailureStage == "RunError" {
|
||||
if m.Failed || m.Contaminated || m.FailureStage == "RunError" {
|
||||
continue
|
||||
}
|
||||
scale := strings.TrimPrefix(m.Arm, "index-")
|
||||
|
||||
@@ -0,0 +1,306 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// blockTask makes one task fail before its first model turn: its own directory
|
||||
// is a regular file, so buildFixture cannot create the session under it. It is
|
||||
// the same early exit as a failed fixture build, session load, or
|
||||
// answer-on-disk guard, and it needs no provider.
|
||||
func blockTask(t *testing.T, root, id string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(root, id), []byte("not a directory\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
// mixedTaskPair is one task whose runs all fail and one whose runs all succeed.
|
||||
// Both are drawn from the corpus, so the batch is the real pipeline.
|
||||
func mixedTaskPair() (blocked, ok contextTask) {
|
||||
blocked, ok = indexTasks()[0], indexTasks()[1]
|
||||
for _, t := range indexTasks() {
|
||||
if t.Role == roleEfficiency && len(armsFor(t)) == 2 {
|
||||
ok = t
|
||||
break
|
||||
}
|
||||
}
|
||||
return blocked, ok
|
||||
}
|
||||
|
||||
// runBlockedExperiment runs one dry index batch over the given tasks, with the
|
||||
// named task failing before any model turn, and returns the batch directory, the
|
||||
// runs, the manifest, and everything the run printed.
|
||||
func runBlockedExperiment(t *testing.T, tasks []contextTask, blocked ...string) (string, []contextMetrics, batchManifest, string) {
|
||||
t.Helper()
|
||||
root := t.TempDir()
|
||||
for _, id := range blocked {
|
||||
blockTask(t, root, id)
|
||||
}
|
||||
out := captureStdout(t, func() {
|
||||
if code := runExperiment("run-index", experimentIndex, root, true, tasks); code != 0 {
|
||||
t.Errorf("runExperiment exit = %d, want 0 (the batch finished)", code)
|
||||
}
|
||||
})
|
||||
runs, _ := readResultLines(t, root)
|
||||
manifest, _ := readManifest(t, root)
|
||||
return root, runs, manifest, out
|
||||
}
|
||||
|
||||
// captureStdout reads everything the function prints. os.Pipe is unbuffered, so
|
||||
// the reader must run beside the call.
|
||||
func captureStdout(t *testing.T, f func()) string {
|
||||
t.Helper()
|
||||
r, w, err := os.Pipe()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
old := os.Stdout
|
||||
os.Stdout = w
|
||||
done := make(chan string, 1)
|
||||
go func() {
|
||||
var b strings.Builder
|
||||
buf := make([]byte, 4096)
|
||||
for {
|
||||
n, err := r.Read(buf)
|
||||
b.Write(buf[:n])
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
}
|
||||
done <- b.String()
|
||||
}()
|
||||
f()
|
||||
os.Stdout = old
|
||||
w.Close()
|
||||
text := <-done
|
||||
r.Close()
|
||||
return text
|
||||
}
|
||||
|
||||
// funnelScored reads the "runs=N" column of the named arm's funnel line out of
|
||||
// the printed tables, so the assertion is on the number a reader sees.
|
||||
func funnelScored(t *testing.T, out, arm string) int {
|
||||
t.Helper()
|
||||
for line := range strings.SplitSeq(out, "\n") {
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 2 || fields[0] != arm || !strings.HasPrefix(fields[1], "runs=") {
|
||||
continue
|
||||
}
|
||||
n, err := strconv.Atoi(strings.TrimPrefix(fields[1], "runs="))
|
||||
if err != nil {
|
||||
t.Fatalf("funnel line for %s: %v\n%s", arm, err, line)
|
||||
}
|
||||
return n
|
||||
}
|
||||
t.Fatalf("no funnel line for arm %s:\n%s", arm, out)
|
||||
return 0
|
||||
}
|
||||
|
||||
// The batch report states both counts on one line, so a reader of the scrollback
|
||||
// sees the scored set and the failures without counting funnel rows.
|
||||
func reportCounts(t *testing.T, out string) (scored, failed int) {
|
||||
t.Helper()
|
||||
for line := range strings.SplitSeq(out, "\n") {
|
||||
if !strings.Contains(line, " scored=") || !strings.Contains(line, " failed=") {
|
||||
continue
|
||||
}
|
||||
for f := range strings.FieldsSeq(line) {
|
||||
switch {
|
||||
case strings.HasPrefix(f, "scored="):
|
||||
scored, _ = strconv.Atoi(strings.TrimPrefix(f, "scored="))
|
||||
case strings.HasPrefix(f, "failed="):
|
||||
failed, _ = strconv.Atoi(strings.TrimPrefix(f, "failed="))
|
||||
}
|
||||
}
|
||||
return scored, failed
|
||||
}
|
||||
t.Fatalf("no line reports scored= and failed=:\n%s", out)
|
||||
return 0, 0
|
||||
}
|
||||
|
||||
// A batch that mixes a task that never ran with one that did must read as its
|
||||
// successful runs only: the failed task leaves Scored, the funnel, and the
|
||||
// completed count, and is reported as a failure with its identity instead.
|
||||
// Every assertion here is a number, not the absence of an empty row.
|
||||
func TestMixedBatchCountsOnlyTheRunThatProducedEvidence(t *testing.T) {
|
||||
blocked, ok := mixedTaskPair()
|
||||
_, runs, manifest, out := runBlockedExperiment(t, []contextTask{blocked, ok}, blocked.ID)
|
||||
|
||||
wantScored, wantFailed := len(armsFor(ok)), len(armsFor(blocked))
|
||||
if manifest.CompletedRuns != wantScored || manifest.FailedRuns != wantFailed {
|
||||
t.Fatalf("manifest completed=%d failed=%d, want %d and %d (planned=%d)",
|
||||
manifest.CompletedRuns, manifest.FailedRuns, wantScored, wantFailed, manifest.PlannedRuns)
|
||||
}
|
||||
if len(runs) != manifest.CompletedRuns+manifest.FailedRuns {
|
||||
t.Fatalf("results.jsonl has %d rows but completed=%d failed=%d",
|
||||
len(runs), manifest.CompletedRuns, manifest.FailedRuns)
|
||||
}
|
||||
for _, id := range manifest.TaskIDs {
|
||||
if id == blocked.ID {
|
||||
t.Fatalf("task_ids lists %s, which produced no evidence: %v", blocked.ID, manifest.TaskIDs)
|
||||
}
|
||||
}
|
||||
scored, failures := 0, 0
|
||||
for _, m := range runs {
|
||||
if m.Failed {
|
||||
failures++
|
||||
if m.Task != blocked.ID || m.FailureStage == "" || m.Reason == "" {
|
||||
t.Fatalf("failed row lost its identity or stage: %+v", m)
|
||||
}
|
||||
continue
|
||||
}
|
||||
scored++
|
||||
if m.Task != ok.ID {
|
||||
t.Fatalf("a run scored from the wrong task: %+v", m)
|
||||
}
|
||||
}
|
||||
if scored != wantScored || failures != wantFailed {
|
||||
t.Fatalf("rows scored=%d failed=%d, want %d and %d", scored, failures, wantScored, wantFailed)
|
||||
}
|
||||
// Each arm's funnel holds exactly the successful task's runs, and the two
|
||||
// scored counts agree with each other and with the manifest.
|
||||
total := 0
|
||||
for _, arm := range armsFor(ok) {
|
||||
total += funnelScored(t, out, arm.name)
|
||||
}
|
||||
if total != manifest.CompletedRuns {
|
||||
t.Fatalf("funnels scored %d runs, manifest completed_runs = %d:\n%s", total, manifest.CompletedRuns, out)
|
||||
}
|
||||
if gotScored, gotFailed := reportCounts(t, out); gotScored != manifest.CompletedRuns || gotFailed != manifest.FailedRuns {
|
||||
t.Fatalf("report says scored=%d failed=%d, manifest says %d and %d",
|
||||
gotScored, gotFailed, manifest.CompletedRuns, manifest.FailedRuns)
|
||||
}
|
||||
if strings.Contains(out, "## Where it broke") && strings.Contains(out, "=-\n") {
|
||||
t.Fatalf("the stage table kept an empty stage:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// A batch where every run fails must not read as an empty success: zero scored
|
||||
// runs, a nonzero failed count, and both stated.
|
||||
func TestAllFailedBatchReportsZeroScoredAndNonzeroFailed(t *testing.T) {
|
||||
blocked, _ := mixedTaskPair()
|
||||
_, runs, manifest, out := runBlockedExperiment(t, []contextTask{blocked}, blocked.ID)
|
||||
|
||||
if manifest.CompletedRuns != 0 {
|
||||
t.Fatalf("completed_runs = %d, want 0 (nothing produced evidence)", manifest.CompletedRuns)
|
||||
}
|
||||
if manifest.FailedRuns != manifest.PlannedRuns || manifest.FailedRuns == 0 {
|
||||
t.Fatalf("failed_runs = %d, planned_runs = %d: every planned run failed",
|
||||
manifest.FailedRuns, manifest.PlannedRuns)
|
||||
}
|
||||
if len(manifest.TaskIDs) != 0 {
|
||||
t.Fatalf("task_ids = %v, want none", manifest.TaskIDs)
|
||||
}
|
||||
for i, m := range runs {
|
||||
if !m.Failed || m.FailureStage == "" || m.Task != blocked.ID {
|
||||
t.Fatalf("results.jsonl line %d is not a marked failure: %+v", i+1, m)
|
||||
}
|
||||
}
|
||||
total := 0
|
||||
for _, arm := range armsFor(blocked) {
|
||||
total += funnelScored(t, out, arm.name)
|
||||
}
|
||||
if total != 0 {
|
||||
t.Fatalf("funnels scored %d runs although nothing ran:\n%s", total, out)
|
||||
}
|
||||
if gotScored, gotFailed := reportCounts(t, out); gotScored != 0 || gotFailed != manifest.FailedRuns {
|
||||
t.Fatalf("report says scored=%d failed=%d, want 0 and %d", gotScored, gotFailed, manifest.FailedRuns)
|
||||
}
|
||||
}
|
||||
|
||||
// The control: with nothing blocked, a batch is what it always was. Every run
|
||||
// is completed with a stage, two identical batches write byte-identical results
|
||||
// and the same manifest header, and the new failure field stays out of it.
|
||||
func TestAllSuccessfulBatchKeepsItsArtifacts(t *testing.T) {
|
||||
tasks := indexTasks()
|
||||
first, runs, manifest, out := runBlockedExperiment(t, tasks)
|
||||
second, again, repeat, _ := runBlockedExperiment(t, tasks)
|
||||
if manifest.CompletedRuns != len(runs) || manifest.FailedRuns != 0 {
|
||||
t.Fatalf("completed=%d failed=%d rows=%d, want every row completed",
|
||||
manifest.CompletedRuns, manifest.FailedRuns, len(runs))
|
||||
}
|
||||
if manifest.CompletedRuns != manifest.PlannedRuns {
|
||||
t.Fatalf("completed=%d planned=%d, want all planned", manifest.CompletedRuns, manifest.PlannedRuns)
|
||||
}
|
||||
for i, m := range runs {
|
||||
if m.Failed || m.Task == "" || m.Arm == "" || m.FailureStage == "" {
|
||||
t.Fatalf("results.jsonl line %d is not a completed run: %+v", i+1, m)
|
||||
}
|
||||
}
|
||||
if gotScored, gotFailed := reportCounts(t, out); gotScored != manifest.CompletedRuns || gotFailed != 0 {
|
||||
t.Fatalf("report says scored=%d failed=%d, want %d and 0", gotScored, gotFailed, manifest.CompletedRuns)
|
||||
}
|
||||
if !reflect.DeepEqual(runs, again) {
|
||||
t.Fatal("two identical successful batches scored differently")
|
||||
}
|
||||
// Byte-for-byte on the runs. Trajectories carry per-message wall-clock
|
||||
// stamps, so they are compared field by field instead.
|
||||
if got, want := readFile(t, second, "results.jsonl"), readFile(t, first, "results.jsonl"); got != want {
|
||||
t.Fatalf("an unchanged batch wrote different results.jsonl:\n%s\n---\n%s", want, got)
|
||||
}
|
||||
gotTraj, wantTraj := withoutStamps(t, readFile(t, second, "trajectories.jsonl")),
|
||||
withoutStamps(t, readFile(t, first, "trajectories.jsonl"))
|
||||
if gotTraj != wantTraj {
|
||||
t.Fatalf("an unchanged batch wrote different trajectories:\n%s\n---\n%s", wantTraj, gotTraj)
|
||||
}
|
||||
repeat.StartedAt, repeat.FinishedAt = manifest.StartedAt, manifest.FinishedAt
|
||||
repeat.TrajectoriesSHA256 = manifest.TrajectoriesSHA256
|
||||
if !reflect.DeepEqual(repeat, manifest) {
|
||||
t.Fatalf("an unchanged batch wrote a different manifest:\n%+v\n---\n%+v", manifest, repeat)
|
||||
}
|
||||
if repeat.FailedRuns != 0 {
|
||||
t.Fatalf("a batch with no failure reports failed_runs=%d", repeat.FailedRuns)
|
||||
}
|
||||
}
|
||||
|
||||
// withoutStamps drops the createdAt stamp every message carries, which is the
|
||||
// one field a dry batch cannot reproduce between two runs.
|
||||
func withoutStamps(t *testing.T, text string) string {
|
||||
t.Helper()
|
||||
var b strings.Builder
|
||||
for line := range strings.SplitSeq(strings.TrimSpace(text), "\n") {
|
||||
var row map[string]any
|
||||
if err := json.Unmarshal([]byte(line), &row); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stripStamp(row)
|
||||
out, err := json.Marshal(row)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b.Write(out)
|
||||
b.WriteByte('\n')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
func stripStamp(v any) {
|
||||
switch typed := v.(type) {
|
||||
case map[string]any:
|
||||
delete(typed, "createdAt")
|
||||
delete(typed, "workDurationMs")
|
||||
for _, child := range typed {
|
||||
stripStamp(child)
|
||||
}
|
||||
case []any:
|
||||
for _, child := range typed {
|
||||
stripStamp(child)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, root, name string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(filepath.Join(root, name))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(data)
|
||||
}
|
||||
@@ -130,7 +130,7 @@ type queryAudit struct {
|
||||
func auditQueries(runs []contextMetrics) queryAudit {
|
||||
a := queryAudit{Reformulations: map[string]int{}, MissedProbeTerms: map[string]int{}}
|
||||
for _, r := range runs {
|
||||
if r.Contaminated {
|
||||
if r.Failed || r.Contaminated {
|
||||
continue
|
||||
}
|
||||
a.Runs++
|
||||
|
||||
@@ -85,6 +85,7 @@ func runAdversarial(root string) int {
|
||||
adversarialPrompts[t.Experiment])
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s: %v\n", id, err)
|
||||
m = failedMetrics(t, "adversarial", err)
|
||||
}
|
||||
verdict := "isolated"
|
||||
if m.EscapeFoundAnswer {
|
||||
@@ -173,6 +174,16 @@ func runOne(p provider.Provider, t contextTask, arm ablation.Set, armName, root
|
||||
return m, runErr
|
||||
}
|
||||
|
||||
// failedMetrics is what a run that never reached a transcript leaves behind. It
|
||||
// carries the run's identity and the stage that ended it, so the failure stays a
|
||||
// countable fact; Failed keeps it out of every statistic.
|
||||
func failedMetrics(t contextTask, armName string, err error) contextMetrics {
|
||||
return contextMetrics{
|
||||
Task: t.ID, Arm: armName, Failed: true,
|
||||
FailureStage: stageNoEvidence, Reason: err.Error(),
|
||||
}
|
||||
}
|
||||
|
||||
// finalAnswer is the last assistant text the turn produced.
|
||||
func finalAnswer(msgs []provider.Message) string {
|
||||
for _, m := range slices.Backward(msgs) {
|
||||
@@ -250,6 +261,7 @@ func runBoundaries(mode, root string) int {
|
||||
m, err := runOne(p, t, armFor(side.scale, false), name, root, side.has, false)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, name, err)
|
||||
m = failedMetrics(t, name, err)
|
||||
}
|
||||
all = append(all, m)
|
||||
if side.has {
|
||||
@@ -349,6 +361,7 @@ func runExperiment(mode, experiment, root string, dry bool, tasks []contextTask)
|
||||
m, err := runOne(p, t, armFor(arm.scale, arm.searchOff), arm.name, root, cueVisible, dry)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "%s [%s]: %v\n", t.ID, arm.name, err)
|
||||
m = failedMetrics(t, arm.name, err)
|
||||
}
|
||||
byArm[arm.name] = append(byArm[arm.name], m)
|
||||
all = append(all, m)
|
||||
@@ -430,11 +443,17 @@ func writeResults(root string, info batchInfo, all []contextMetrics) int {
|
||||
return 2
|
||||
}
|
||||
defer f.Close()
|
||||
scored, failed := 0, 0
|
||||
for _, m := range all {
|
||||
if err := writeJSONLine(f, m); err != nil {
|
||||
fmt.Fprintln(os.Stderr, err)
|
||||
return 2
|
||||
}
|
||||
if m.Failed {
|
||||
failed++
|
||||
} else {
|
||||
scored++
|
||||
}
|
||||
}
|
||||
if err := writeTrajectories(root, all); err != nil {
|
||||
fmt.Fprintln(os.Stderr, "write trajectories:", err)
|
||||
@@ -444,7 +463,10 @@ func writeResults(root string, info batchInfo, all []contextMetrics) int {
|
||||
fmt.Fprintln(os.Stderr, "write batch manifest:", err)
|
||||
return 2
|
||||
}
|
||||
fmt.Printf("\n%d runs written to %s (with trajectories.jsonl and batch.json)\n", len(all), path)
|
||||
// Both counts are named: a reader who sees only the batch size cannot tell
|
||||
// measurements from attempts.
|
||||
fmt.Printf("\n%d runs written to %s: scored=%d failed=%d (with trajectories.jsonl and batch.json)\n",
|
||||
len(all), path, scored, failed)
|
||||
return 0
|
||||
}
|
||||
|
||||
@@ -453,9 +475,21 @@ func writeResults(root string, info batchInfo, all []contextMetrics) int {
|
||||
// what a four-arm average cannot do.
|
||||
func reportBoundaries(all []contextMetrics) {
|
||||
byKey := map[string]contextMetrics{}
|
||||
scored, failed := 0, 0
|
||||
for _, m := range all {
|
||||
if m.Failed {
|
||||
// No transcript, no boundary to compare: count the failure instead of
|
||||
// printing the zero it left behind as a measurement.
|
||||
failed++
|
||||
continue
|
||||
}
|
||||
scored++
|
||||
byKey[m.Task+"|"+m.Arm] = m
|
||||
}
|
||||
if scored == 0 {
|
||||
fmt.Printf("\n## Across each task's cue boundary\n no scored runs: scored=%d failed=%d (nothing to compare)\n", scored, failed)
|
||||
return
|
||||
}
|
||||
var cueSide, noCueSide []contextMetrics
|
||||
fmt.Println("\n## Across each task's cue boundary")
|
||||
fmt.Printf(" %-24s %-16s %-4s %-10s %-9s %-12s %-11s %s\n",
|
||||
|
||||
@@ -448,6 +448,7 @@ func runtimeOptions(root string, sink event.Sink, logs io.Writer, phases *startu
|
||||
OnPhase: phases.boot,
|
||||
StatsSource: surface.Desktop,
|
||||
FeedbackSurface: feedback.SurfaceStudio,
|
||||
AccountsURL: os.Getenv("REASONIX_ACCOUNTS_URL"),
|
||||
// A stale default_model must not keep the window from opening to fix it.
|
||||
OpenOnFallbackModel: true,
|
||||
|
||||
|
||||
@@ -78,6 +78,7 @@ node perf/sidebar.mjs # 侧栏开关只有一个且位置不变(图标栏
|
||||
node perf/stickyfold.mjs # 展开的长执行过程:滚动时头部吸在顶部、有自己的底(浅/深)
|
||||
node perf/crumb.mjs # 标题栏里会话标题紧跟项目名,长名字也不溢出(宽/窄)
|
||||
node perf/hoverradius.mjs # 输入框底行的按钮悬停时圆角和底色一致(浅/深)
|
||||
node perf/jump-ends.mjs # 回到顶部/跳到最新按钮一次点击直达两端(中间有展开块也一样)
|
||||
node perf/sessionrows.mjs # 会话行样式:34px、无额外行间距、单行省略;本机/远程、悬浮/焦点、浅/深、窄/宽
|
||||
node perf/start.mjs # 经工作台标签/边缘把手打开工作台先出入口页(文件/改动/浏览器),不替人开浏览器;标题栏 Browser 按钮仍直达浏览器(浅/深 × 宽/窄)
|
||||
```
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
// The context card opens on hover and holds buttons (compress now, manage MCP).
|
||||
// Reaching them means crossing the gap between the meter and the card; a card
|
||||
// that closes while the pointer is in that gap can never be used. Hover is a
|
||||
// layout question, so it is asked of a real browser, with the pointer moving
|
||||
// the way a hand does: in steps, through the gap.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 }, colorScheme: scheme, locale: "zh-CN" });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".studio-meter-context", { timeout: 20000 });
|
||||
const trigger = page.locator(".studio-meter-context").first();
|
||||
const tb = await trigger.boundingBox();
|
||||
await page.mouse.move(tb.x + tb.width / 2, tb.y + tb.height / 2);
|
||||
await page.waitForTimeout(250);
|
||||
const card = page.locator(".studio-context-anchor .chrome-context-card");
|
||||
check(`${scheme}: the card opens while the pointer is on the meter`, await card.isVisible());
|
||||
const target = card.locator("button, a").first();
|
||||
const cb = await target.boundingBox();
|
||||
// Up through the gap in small steps, then onto the card's own control.
|
||||
await page.mouse.move(cb.x + cb.width / 2, cb.y + cb.height / 2, { steps: 40 });
|
||||
await page.waitForTimeout(250);
|
||||
check(`${scheme}: the card is still open after crossing the gap`, await card.isVisible());
|
||||
const hit = await target.evaluate((el) => {
|
||||
const r = el.getBoundingClientRect();
|
||||
const at = document.elementFromPoint(r.left + r.width / 2, r.top + r.height / 2);
|
||||
return at === el || el.contains(at);
|
||||
});
|
||||
check(`${scheme}: its first control takes the pointer`, hit);
|
||||
await ctx.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -0,0 +1,39 @@
|
||||
// Over a wallpaper the tab strip sits above the conversation, whose own backdrop
|
||||
// fades out on its other three sides. A strip that ends on a hard line reads as a
|
||||
// seam. Whether the strip's lower edge fades is a property of the render, so it
|
||||
// is read from the browser: the strip draws no bottom border over a wallpaper and
|
||||
// carries a gradient below its own box.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1300, height: 860 }, colorScheme: scheme, locale: "zh-CN" });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(`${BASE}?pack=photo&turns=3`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".tabs");
|
||||
const edge = await page.evaluate(() => {
|
||||
const tabs = document.querySelector(".tabs");
|
||||
const own = getComputedStyle(tabs);
|
||||
const fade = getComputedStyle(tabs, "::after");
|
||||
return { border: own.borderBottomColor, fadeHeight: parseFloat(fade.height) || 0, fadeImage: fade.backgroundImage };
|
||||
});
|
||||
check(`${scheme}: the tab strip draws no hard bottom line over a wallpaper`, edge.border === "rgba(0, 0, 0, 0)", edge.border);
|
||||
check(`${scheme}: it fades into the conversation below`, edge.fadeHeight >= 12 && edge.fadeImage.includes("linear-gradient"), `${edge.fadeHeight}px`);
|
||||
await ctx.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -7,9 +7,11 @@ import "../src/styles/app.css";
|
||||
import "../src/styles/dock.css";
|
||||
import "../src/styles/studio.css";
|
||||
import "../src/styles/changedpill.css";
|
||||
import "../src/styles/askskip.css";
|
||||
import "../src/styles/pastekeep.css";
|
||||
import "../src/styles/ctxitems.css";
|
||||
import "../src/styles/sidepanel.css";
|
||||
import "../src/styles/planpeek.css";
|
||||
import "../src/styles/scrolljump.css";
|
||||
import "../src/styles/chart.css";
|
||||
import "../src/styles/community.css";
|
||||
@@ -37,6 +39,7 @@ import "../src/styles/feedback.css";
|
||||
import "../src/styles/toasts.css";
|
||||
import "../src/styles/modelmenu.css";
|
||||
import "../src/styles/runsignals.css";
|
||||
import "../src/styles/overview.css";
|
||||
import "../src/styles/themes.css";
|
||||
import { App } from "../src/ui/App";
|
||||
import { MockHub } from "../src/port/mock_hub";
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
// The two floating buttons are named for the ends they go to ("back to top", "jump to latest"),
|
||||
// so one click has to arrive there, whatever expanded blocks lie between. Where a scroller ends up
|
||||
// is a property of the render, so it is read from the browser.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html?pref=zh&ws=2&sess=6&turns=40";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
const page = await browser.newPage({ viewport: { width: 1100, height: 760 } });
|
||||
await page.goto(BASE, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector('[data-pane="flow"]');
|
||||
const geometry = () => page.locator('[data-pane="flow"]').evaluate((el) => ({ top: Math.round(el.scrollTop), room: Math.round(el.scrollHeight - el.clientHeight - el.scrollTop) }));
|
||||
await page.locator('[data-pane="flow"]').evaluate((el) => { el.scrollTop = Math.round((el.scrollHeight - el.clientHeight) / 2); });
|
||||
await page.waitForSelector('.scrolljump button[data-value="up"]');
|
||||
await page.waitForSelector('.scrolljump button[data-value="down"]');
|
||||
await page.evaluate(() => document.querySelectorAll("details.tool-disclosure, details.think").forEach((d) => { d.open = true; }));
|
||||
await page.waitForTimeout(200);
|
||||
const open = await page.locator("details.tool-disclosure[open], details.think[open]").count();
|
||||
check("there are expanded blocks between the middle and each end", open >= 2, `${open} expanded blocks`);
|
||||
check("the conversation is long enough to scroll", (await geometry()).top > 0 && (await geometry()).room > 0);
|
||||
|
||||
await page.click('.scrolljump button[data-value="up"]');
|
||||
await page.waitForTimeout(400);
|
||||
const atTop = await geometry();
|
||||
check("one click on the top button reaches the top", atTop.top <= 1, `scrollTop ${atTop.top}`);
|
||||
|
||||
await page.locator('[data-pane="flow"]').evaluate((el) => { el.scrollTop = Math.round((el.scrollHeight - el.clientHeight) / 2); });
|
||||
await page.waitForSelector('.scrolljump button[data-value="down"]');
|
||||
await page.click('.scrolljump button[data-value="down"]');
|
||||
await page.waitForTimeout(800);
|
||||
const atEnd = await geometry();
|
||||
check("one click on the latest button reaches the end", atEnd.room <= 2, `${atEnd.room}px from the end`);
|
||||
await page.close();
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
@@ -107,6 +107,28 @@ const browser = await chromium.launch();
|
||||
check("at every room from 190 to 460 the title keeps its 4em floor", bad.length === 0, bad.join(" "));
|
||||
await page.close();
|
||||
}
|
||||
// A running turn swaps Send for Stop, which is wider; the toolbar must still sit on one line at every column width.
|
||||
{
|
||||
const page = await browser.newPage({ viewport: { width: 1280, height: 800 } });
|
||||
await page.goto(`${BASE}?pref=zh&turns=2`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".compose");
|
||||
await page.fill('textarea[role="combobox"]', "hello");
|
||||
await page.keyboard.press("Enter");
|
||||
await page.waitForSelector(".btn.stop");
|
||||
const bad = [];
|
||||
for (let room = 420; room <= 760; room += 20) {
|
||||
await page.setViewportSize({ width: room, height: 800 });
|
||||
await page.waitForTimeout(150);
|
||||
const lines = await page.evaluate(() => {
|
||||
const seen = [...document.querySelectorAll(".compose .row button")].filter((el) => el.getBoundingClientRect().width > 0 && el.checkVisibility());
|
||||
const mids = seen.map((el) => { const r = el.getBoundingClientRect(); return r.top + r.height / 2; }).sort((a, b) => a - b);
|
||||
return mids.filter((m, i) => i === 0 || m - mids[i - 1] > 6).length;
|
||||
});
|
||||
if (lines !== 1) bad.push(`${room}:${lines}`);
|
||||
}
|
||||
check("while a turn runs, the composer toolbar stays on one line from 420 to 760", bad.length === 0, bad.join(" "));
|
||||
await page.close();
|
||||
}
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
for (const [width, zoom] of [[390, 1.8], [390, 1.3], [320, 1.8], [320, 1], [1280, 1]]) {
|
||||
const page = await browser.newPage({ viewport: { width, height: 800 }, colorScheme: scheme, reducedMotion: "reduce" });
|
||||
|
||||
@@ -0,0 +1,43 @@
|
||||
// The plan chip sits at the right edge of the tab row, beside the side-question
|
||||
// toggle. Two auto margins in one flex row split the free space and leave the
|
||||
// chip floating in the middle; the real stylesheet and real geometry decide it.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
for (const scheme of ["light", "dark"]) {
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 900 }, colorScheme: scheme });
|
||||
page.on("pageerror", (e) => fails.push("page error: " + e.message));
|
||||
await page.goto(`${BASE}?pref=zh&turns=3`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".pane > .tabs .sidetoggle");
|
||||
await page.evaluate(() => {
|
||||
const toggle = document.querySelector(".pane > .tabs .sidetoggle");
|
||||
const chip = document.createElement("div");
|
||||
chip.className = "plan-peek";
|
||||
chip.innerHTML = '<button type="button" class="tab plan-peek-trigger">计划 <small>0/12</small></button>';
|
||||
toggle.before(chip);
|
||||
});
|
||||
if (process.env.BEFORE) await page.addStyleTag({ content: ".plan-peek + .sidetoggle { margin-left: auto }" });
|
||||
await page.waitForTimeout(200);
|
||||
const g = await page.evaluate(() => {
|
||||
const chip = document.querySelector(".pane > .tabs .plan-peek").getBoundingClientRect();
|
||||
const toggle = document.querySelector(".pane > .tabs .sidetoggle").getBoundingClientRect();
|
||||
const row = document.querySelector(".pane > .tabs").getBoundingClientRect();
|
||||
return { gap: Math.round(toggle.left - chip.right), chipRight: Math.round(chip.right), rowRight: Math.round(row.right) };
|
||||
});
|
||||
check(`${scheme}: the plan chip sits against the side toggle, not mid-row`, g.gap >= 0 && g.gap <= 16, JSON.stringify(g));
|
||||
if (process.env.SHOT) await page.locator(".pane > .tabs").screenshot({ path: `${process.env.SHOT}-${scheme}.png` });
|
||||
await page.close();
|
||||
}
|
||||
await browser.close();
|
||||
if (fails.length) {
|
||||
console.log(`\n${fails.length} failed`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall ok");
|
||||
@@ -0,0 +1,46 @@
|
||||
// The session tab's dot is the only at-a-glance sign of a working session: it
|
||||
// breathes while the run is live and stays still in every other state, and a
|
||||
// person who asked for reduced motion gets it still. Only the browser computes it.
|
||||
import { chromium } from "playwright";
|
||||
|
||||
const BASE = process.env.PERF_URL ?? "http://localhost:4399/perf.html";
|
||||
const fails = [];
|
||||
const check = (name, ok, detail = "") => {
|
||||
console.log(`${ok ? " ok" : "FAIL"} ${name}${detail ? " — " + detail : ""}`);
|
||||
if (!ok) fails.push(name);
|
||||
};
|
||||
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const [label, reduced] of [["normal", "no-preference"], ["reduced motion", "reduce"]]) {
|
||||
const ctx = await browser.newContext({ viewport: { width: 1200, height: 760 }, reducedMotion: reduced });
|
||||
const page = await ctx.newPage();
|
||||
await page.goto(`${BASE}?pack=photo`, { waitUntil: "networkidle" });
|
||||
await page.waitForSelector(".app");
|
||||
const names = await page.evaluate(() => {
|
||||
const bar = document.createElement("div");
|
||||
bar.className = "panetabs";
|
||||
bar.style.cssText = "position:fixed;left:0;top:0;opacity:0;pointer-events:none";
|
||||
bar.innerHTML = ["idle", "running", "halt", "done"].map((r) => `<div class="ptab" data-run="${r}"><i class="pip"></i></div>`).join("");
|
||||
bar.innerHTML += `<div class="ptab" data-run="done"><i class="pip" data-unread></i></div>`;
|
||||
document.body.appendChild(bar);
|
||||
const out = {};
|
||||
for (const r of ["idle", "running", "halt", "done"]) out[r] = getComputedStyle(bar.querySelector(`[data-run="${r}"] .pip`)).animationName;
|
||||
const read = getComputedStyle(bar.querySelector('[data-run="done"] .pip:not([data-unread])'));
|
||||
const unread = getComputedStyle(bar.querySelector('[data-run="done"] .pip[data-unread]'));
|
||||
out.unreadDiffers = unread.backgroundColor !== read.backgroundColor && unread.backgroundColor !== "rgba(0, 0, 0, 0)";
|
||||
return out;
|
||||
});
|
||||
check(`${label}: a running session's dot ${reduced === "reduce" ? "does not move" : "breathes"}`, reduced === "reduce" ? names.running === "none" : names.running === "breathe", names.running);
|
||||
check(`${label}: idle, halted and finished dots stay still`, names.idle === "none" && names.halt === "none" && names.done === "none", JSON.stringify(names));
|
||||
check(`${label}: an unread finished session is told apart by its own dot, not an extra one`, names.unreadDiffers === true);
|
||||
await ctx.close();
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
if (fails.length) {
|
||||
console.error(`\n${fails.length} failed: ${fails.join("; ")}`);
|
||||
process.exit(1);
|
||||
}
|
||||
console.log("\nall passed");
|
||||
@@ -104,6 +104,7 @@ export const ACTIONS: UIAction[] = [
|
||||
// Closing a conversation's pane ends its runtime and keeps the transcript;
|
||||
// refused while a turn runs, so it never stops work.
|
||||
{ id: "session.close", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "session.branch", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "session.archive", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "session.copy-info", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "session.export", kind: "shell-native", target: "entity", proof: "browser" },
|
||||
@@ -245,6 +246,9 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "feedback.mine", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.logs", kind: "shell-native", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.refresh", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-link", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-unlink", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.account-unlink-other", kind: "kernel-mutation", target: "entity", proof: "interaction" },
|
||||
{ id: "feedback.retry-env", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "feedback.retry-mine", kind: "repeatable", target: "none", proof: "interaction" },
|
||||
{ id: "paste.save", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
@@ -381,6 +385,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "remote-host.remove", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "workspace.reveal", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "workspace.open-with", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.open-with-menu", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workspace.remove", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "extensions.invoke", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
|
||||
@@ -481,6 +486,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "versions.notes-lang", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "shell.executor", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "shell.custom-path", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "shell.custom-args", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "hooks.recipe", kind: "kernel-mutation", target: "entity", proof: "authority-effect" },
|
||||
{ id: "hooks.remove", kind: "destructive", target: "none", proof: "authority-effect" },
|
||||
{ id: "hooks.run-once", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
@@ -558,6 +564,7 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "workbench.files", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.hidden", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "workbench.reveal", kind: "shell-native", target: "optional", proof: "interaction" },
|
||||
{ id: "workbench.rename-tab", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.open-file", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.copy-path", kind: "shell-native", target: "entity", proof: "interaction" },
|
||||
{ id: "workbench.add-to-chat", kind: "view", target: "entity", proof: "interaction" },
|
||||
@@ -598,6 +605,14 @@ export const ACTIONS: UIAction[] = [
|
||||
{ id: "sandbox.add-write-root", kind: "kernel-mutation", target: "optional", proof: "authority-effect" },
|
||||
{ id: "sandbox.remove-write-root", kind: "destructive", target: "entity", proof: "authority-effect" },
|
||||
{ id: "browser-tools.enabled", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "update.mode", kind: "kernel-mutation", target: "none", proof: "interaction" },
|
||||
{ id: "skills.update-open", kind: "view", target: "entity", proof: "interaction" },
|
||||
{ id: "skills.update-close", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "skills.update-overwrite", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "skills.update-hidden", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "skills.update-reload", kind: "view", target: "none", proof: "interaction" },
|
||||
{ id: "skills.update-apply", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
{ id: "subagent-claims.optimistic", kind: "kernel-mutation", target: "none", proof: "authority-effect" },
|
||||
// Read by the running turn at its next round, so no rebuild stands between the
|
||||
// click and the canonical change.
|
||||
{ id: "auto-archive.notice-dismiss", kind: "view", target: "none", proof: "interaction" },
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { HttpError } from "../port/http_error";
|
||||
import { STORAGE, boot } from "./index";
|
||||
import { reason } from "./kernel";
|
||||
|
||||
const refused = new HttpError(409, "config does not parse", { code: "config.unparsed", params: { path: "C:\\Users\\a\\.reasonix\\config.toml", line: 12 } });
|
||||
|
||||
describe("a settings save refused because the config file does not parse", () => {
|
||||
it("says where to see and repair it, not only that it failed", () => {
|
||||
expect(reason(refused)).toContain("设置");
|
||||
});
|
||||
|
||||
it("says the same in English", () => {
|
||||
localStorage.setItem(STORAGE, "en");
|
||||
boot();
|
||||
try {
|
||||
expect(reason(refused)).toContain("Settings");
|
||||
} finally {
|
||||
localStorage.setItem(STORAGE, "zh");
|
||||
boot();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -352,6 +352,8 @@ export const EN: Record<string, string> = {
|
||||
"删除主题「{name}」": "Delete theme \"{name}\"",
|
||||
"删除这份恢复副本?": "Delete this recovery copy?",
|
||||
"连同其记录一并删除": "Deletes it together with its record",
|
||||
"还有 {n} 份恢复副本带着这个会话没有的内容,直接删除会保留它们": "{n} recovery copies hold content this conversation lacks; a plain delete keeps them",
|
||||
"连同 {n} 份副本删除": "Delete with {n} copies",
|
||||
"恢复副本": "Recovery",
|
||||
"早先版本 · {n} 轮": "Earlier version · {n} turns",
|
||||
"删除这个早先版本?": "Delete this earlier version?",
|
||||
@@ -600,6 +602,12 @@ export const EN: Record<string, string> = {
|
||||
"留空时按这个顺序尝试:先 ssh-agent 持有的密钥,再 ~/.ssh 下的默认密钥;只有填了下面的环境变量或该机器要求交互时,才会走密码。": "Left blank, it tries in this order: keys held by ssh-agent, then the default keys under ~/.ssh. A password is only used when one of the variables below is set, or the machine asks interactively.",
|
||||
"在代码编辑器中打开工作区": "Open the workspace in a code editor",
|
||||
"在系统文件管理器中显示工作区": "Show the workspace in the system file manager",
|
||||
"用其他程序打开项目": "Open the project in another program",
|
||||
"没有找到可用的程序": "No supported program found",
|
||||
"文件管理器": "File manager",
|
||||
"终端": "Terminal",
|
||||
"在系统文件管理器中显示此文件": "Show this file in the system file manager",
|
||||
"重命名页签": "Rename tab",
|
||||
"显示隐藏文件": "Show hidden files",
|
||||
"在访达中显示": "Reveal in Finder",
|
||||
"在文件资源管理器中显示": "Reveal in File Explorer",
|
||||
@@ -712,12 +720,16 @@ export const EN: Record<string, string> = {
|
||||
"指定一个可执行文件": "Point at an executable",
|
||||
"取消指定": "Clear it",
|
||||
"可执行文件路径": "Path to the executable",
|
||||
"启动命令": "Launch command",
|
||||
"保存的启动参数现在无法运行,已回退到自动检测。": "The saved launcher arguments no longer run, so auto-detection is in use.",
|
||||
"启动参数(可选,每行一项)": "Launcher arguments (optional, one per line)",
|
||||
"例如 wsl.exe 需要 -d <发行版> -- /bin/bash 才会进入 shell。参数按原样传给程序,不经过任何 shell;保存前会带着它们运行一次来验证。": "For example wsl.exe needs -d <distro> -- /bin/bash to reach a shell. Arguments are passed to the program as written, through no shell; they are run once to verify before saving.",
|
||||
"自行编译的 bash、MSYS2,或安装在其他位置的 pwsh 均可填写于此。保存前会实际执行一条命令进行验证,无法运行则不会写入配置。":
|
||||
"A bash you built yourself, MSYS2, a pwsh installed elsewhere — all go here. Before saving, one command is really run through it; if that fails, nothing is written to the config.",
|
||||
"切换失败": "Not changed",
|
||||
"够不着这个工作目录。": "cannot reach this working directory.",
|
||||
"本机没有 bash,因此命令只能按 PowerShell 语法编写。安装 Git for Windows 后会增加 Git Bash 选项 —— WSL 中的 bash 不适用,它看到的是 /mnt 下的另一套路径,无法访问当前工作目录。":
|
||||
"There is no bash on this machine, so commands can only be written for PowerShell. Installing Git for Windows adds Git Bash here — the one inside WSL does not count: it sees a different set of paths under /mnt and cannot reach this working directory.",
|
||||
"本机没有 bash,因此命令只能按 PowerShell 语法编写。安装 Git for Windows 后会增加 Git Bash 选项。要用 WSL,请在下方指定 wsl.exe 并填写启动参数;其中的路径是 Linux 写法。":
|
||||
"There is no bash on this machine, so commands can only be written for PowerShell. Installing Git for Windows adds Git Bash here. To use WSL, point at wsl.exe below and add its launcher arguments; paths inside it are Linux paths.",
|
||||
"无法读取 shell 配置。": "Could not read the shell config.",
|
||||
|
||||
// ── 卡片:批准、还原、收工 ───────────────────────────────────────
|
||||
@@ -1358,6 +1370,8 @@ export const EN: Record<string, string> = {
|
||||
"重新生成": "Regenerate",
|
||||
"分支为新会话": "Branch into a new conversation",
|
||||
"分支": "branch",
|
||||
"从最近回复分叉": "Branch from the latest reply",
|
||||
"还没有已完成的回复可以分叉": "There is no finished reply to branch from yet",
|
||||
"当前回复会留在运行历史里": "The current reply stays in the run history",
|
||||
"按当前配置重试": "Retry as configured",
|
||||
"先调整模型与强度": "Adjust the model and effort first",
|
||||
|
||||
@@ -43,6 +43,13 @@ export const EN_COMPOSER: Record<string, string> = {
|
||||
"当前分支 · {branch}": "Current branch · {branch}",
|
||||
"非 Git 仓库": "No Git repository",
|
||||
"此工作区未受版本控制": "This workspace is not version-controlled",
|
||||
"Git 拒绝打开": "Git refused to open it",
|
||||
"Git 未响应": "Git did not respond",
|
||||
"这个文件夹有 Git 仓库,但 Git 拒绝打开它": "This folder has a Git repository, but Git refused to open it",
|
||||
"这个文件夹有 Git 仓库,但 Git 没有及时响应": "This folder has a Git repository, but Git did not respond in time",
|
||||
"Git 说:{detail}": "Git said: {detail}",
|
||||
"请在终端里运行 git status 查看原因": "Run git status in a terminal to see why",
|
||||
"分支暂时无法显示。重新打开这个文件夹,或重启 Studio 后再试": "Branches cannot be shown for now. Reopen this folder, or restart Studio, and try again",
|
||||
"这里没有 Git 仓库,因此没有分支可显示或切换": "There is no Git repository here, so there is no branch to show or switch to",
|
||||
"HEAD 分离 · {sha}": "Detached HEAD · {sha}",
|
||||
"本地分支": "Local branches",
|
||||
|
||||
@@ -144,6 +144,8 @@ export const EN_FEEDBACK: Record<string, string> = {
|
||||
"{n} 项待查看": "{n} to review",
|
||||
"有 {n} 份反馈收到了新回复。": "{n} of your reports have new replies.",
|
||||
"回复已发送。": "Reply sent.",
|
||||
"也可以直接粘贴。PNG 或 JPEG,最多 {n} 张,每张不超过 {size} MB。": "You can also paste. PNG or JPEG, up to {n}, each up to {size} MB.",
|
||||
"截图先只发给维护者,由维护者决定是否公开。": "Screenshots go to the maintainers only, who decide whether to make them public.",
|
||||
"反馈等级": "Feedback level",
|
||||
"升级进度": "Progress to the next level",
|
||||
"新种": "New seed",
|
||||
@@ -176,4 +178,20 @@ export const EN_FEEDBACK: Record<string, string> = {
|
||||
"约 {n} 分钟后重置。": "It resets in about {n} min.",
|
||||
"约 {n} 小时后重置。": "It resets in about {n} h.",
|
||||
"这份反馈的回复次数已到上限,不会自动重置。如有新的情况,可以另外提交一条反馈。": "This report has reached its reply limit, and it does not reset. If something new comes up, send another report.",
|
||||
"账号关联": "Account link",
|
||||
"关联后,这台电脑的反馈等级会显示在网页账号页上。关联不会改变额度,随时可以解除。": "Once linked, this computer's feedback level shows on your account page on the web. Linking does not change your limits, and you can unlink at any time.",
|
||||
"先在设置里登录账号,才能关联。": "Sign in to your account in Settings first, then you can link.",
|
||||
"发出第一条反馈之后才能关联这台电脑。": "You can link this computer after you have sent your first report.",
|
||||
"这台电脑已关联": "This computer is linked",
|
||||
"解除关联": "Unlink",
|
||||
"把这台电脑关联到我的账号": "Link this computer to my account",
|
||||
"账号下的其他电脑": "Other computers on this account",
|
||||
"解除": "Unlink",
|
||||
"没能连上反馈服务,请稍后重试。": "Could not reach the feedback service. Please try again later.",
|
||||
"需要先登录账号。登录状态可能已过期,请在设置里重新登录。": "Sign in to your account first. Your sign-in may have expired; sign in again in Settings.",
|
||||
"这台电脑已经关联到另一个账号。请先用那个账号解除关联。": "This computer is already linked to another account. Unlink it from that account first.",
|
||||
"这个账号关联的电脑已达上限。请先解除一台不用的电脑。": "This account has reached its limit of linked computers. Unlink one you no longer use.",
|
||||
"这台电脑还没有发过反馈,发出第一条之后才能关联账号。": "This computer has not sent a report yet. You can link it to an account after the first one.",
|
||||
"账号关联暂时不可用,请稍后再试。": "Account linking is unavailable right now. Please try again later.",
|
||||
"操作没有完成,请稍后重试。": "That did not finish. Please try again later.",
|
||||
};
|
||||
@@ -46,6 +46,10 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试": "This report has reached its reply limit, or you replied too often - try again later",
|
||||
"这份反馈现在不接收回复": "This report takes no reply right now",
|
||||
"反馈服务要求额外验证,请稍后再试": "The feedback service asks for extra verification - try again later",
|
||||
"需要先登录账号": "Sign in to your account first",
|
||||
"这台电脑已关联到另一个账号": "This computer is already linked to another account",
|
||||
"这个账号关联的电脑已达上限": "This account has reached its limit of linked computers",
|
||||
"这台电脑还没有发过反馈": "This computer has not sent a report yet",
|
||||
"截图无法清除其中的元数据,请换一张或先用截图工具重新导出": "An image could not be cleaned of its metadata - pick another or re-export it",
|
||||
"反馈内容不符合要求,请检查后重试": "That feedback could not be accepted - check the fields and try again",
|
||||
"反馈内容过大,请缩短文字或减少截图": "That feedback is too large - shorten the text or attach fewer screenshots",
|
||||
@@ -70,6 +74,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
// 待送达:条目、队列、这份存档各自会拒
|
||||
"该条已不在待送达队列中": "That entry is no longer in the pending queue",
|
||||
"这个后台任务已经不在运行了": "That background job is no longer running",
|
||||
"已经请求停止这个后台任务,它的进程还没有退出,仍在等待": "A stop was already requested for this background job; its process has not exited yet, so it is still shutting down",
|
||||
"该条当前状态不允许此操作": "That entry's current state does not allow this",
|
||||
"待送达已暂停,请先恢复派发再进行操作": "The pending queue is on hold - resume it first",
|
||||
"待送达条数已达上限,请先发送部分内容": "The pending queue is full - let a few go out first",
|
||||
@@ -80,7 +85,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"该待送达由更高版本写入,当前版本只能读取": "This queue was written by a newer version and is read-only here",
|
||||
"该提交标识已被使用,且当时的内容不同": "That submission key was already used, with different content",
|
||||
// 配置文件本身坏了,以及每一个写设置的面板被它挡住时
|
||||
"配置文件无法读取,因此本次未保存": "The config file does not parse, so nothing was saved",
|
||||
"配置文件无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复": "The config file does not parse, so nothing was saved. Open Settings to see where it is broken and repair it",
|
||||
"这台服务器未开放配置编辑": "This server does not allow editing its config",
|
||||
"该文件需手动修改:{detail}": "This file needs an edit by hand: {detail}",
|
||||
"设置已写入,但运行时未能按新设置重建:{detail}": "The settings were written, but the runtime could not be rebuilt on them: {detail}",
|
||||
@@ -89,6 +94,10 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"沙箱设置未能保存:{detail}": "The sandbox settings were not saved: {detail}",
|
||||
"内置浏览器设置未能保存:{detail}": "The built-in browser setting was not saved: {detail}",
|
||||
"本次请求未说明开关状态,未做任何修改": "The request did not say on or off, so nothing changed",
|
||||
"这台服务器未开放子代理写入范围设置": "This server does not open the sub-agent write scope setting",
|
||||
"子代理写入范围设置未能保存:{detail}": "The sub-agent write scope setting was not saved: {detail}",
|
||||
"本次请求未说明选择,未做任何修改": "The request did not say which, so nothing changed",
|
||||
"这个路径正被另一个并行的子代理或写入占用。本次写入未执行;请换一个路径,或等对方结束后再写": "This path is in use by another parallel sub-agent or write. This write did not run; choose another path, or wait until it finishes",
|
||||
"无法解析该服务器声明:{detail}": "That server declaration could not be read: {detail}",
|
||||
"未能安装该服务器:{detail}": "That server could not be installed: {detail}",
|
||||
"未能移除该服务器:{detail}": "That server could not be removed: {detail}",
|
||||
@@ -159,6 +168,7 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"这个文件夹已经在当前工作区里": "This folder is already inside the current workspace.",
|
||||
"这个文件夹范围太大(磁盘根目录,或包含你的主目录或 Reasonix 数据目录)": "This folder is too broad: it is a drive root or contains your home or Reasonix data folder.",
|
||||
"这个文件夹含有或位于密钥、凭据或 Reasonix 自己的数据目录里,不能授予写入权限": "This folder holds or sits inside keys, credentials or Reasonix's own data, so it cannot be granted write access.",
|
||||
"这里是 Reasonix 保存已安装技能的位置,不能授予写入权限。想让 AI 持续改进自己的技能:把技能的源文件夹用“登记”(register)方式安装,之后在源文件夹里修改。": "This is where Reasonix keeps installed skills, so it cannot be granted write access. To let the AI keep improving a skill of yours, install the skill's source folder in register mode and edit it there.",
|
||||
"这个文件夹不属于当前用户,不能授予写入权限": "This folder is not owned by the current user, so it cannot be granted write access.",
|
||||
"这个文件夹和已有的可写位置(临时目录、工具缓存或另一个额外文件夹)重叠": "This folder overlaps a location that is already writable (temp, a tool cache or another extra folder).",
|
||||
"没有这个预设": "No curated preset has this id.",
|
||||
@@ -365,7 +375,10 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"备份数量已达上限,请先删除一份旧备份": "The backup limit is reached; delete an older backup first",
|
||||
"有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复": "Some items run programs here or change where a key is sent; confirm each one before restoring",
|
||||
"项目列表已满(32 个),请先移除一个项目再添加": "The project list is full (32 projects); remove one before adding another",
|
||||
"{model} 的价格必须是不小于 0 的数字,并选择货币": "The price for {model} must be a number not below 0, with a currency chosen",
|
||||
"{model} 不在已选模型中,未保存价格": "{model} is not one of the selected models, so no price was saved",
|
||||
"这台服务器不支持切换工作区": "This server cannot switch workspaces",
|
||||
"没有找到这个分支,没有创建隔离副本": "That branch was not found, so no isolated copy was created",
|
||||
"不存在该预设": "There is no such preset",
|
||||
"没有找到这个模型,默认模型没有改动": "That model was not found, so the default model is unchanged",
|
||||
"这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置": "This pane's default model is decided by the machine its models come from; set it from a pane on this machine",
|
||||
@@ -411,7 +424,18 @@ export const EN_KERNEL: Record<string, string> = {
|
||||
"这个内核没有窗口外壳,没有可以指出的日志目录": "This kernel has no window shell, so there is no log directory to point to",
|
||||
"这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本":
|
||||
"This Studio is not an installed build (it runs from source), so it has no version to show or change",
|
||||
"这不是一个技能名": "That is not a skill name.",
|
||||
"这个技能没有安装来源的记录,无法从源文件夹更新": "This skill has no recorded source, so it cannot be updated from a folder.",
|
||||
"这个技能不是从本机文件夹复制安装的,无法从源文件夹更新(链接或远程来源请重新安装)": "This skill was not copied from a local folder, so it cannot be updated from one (reinstall a linked or remote skill).",
|
||||
"找不到来源文件夹,可能被移动或删除了": "The source folder cannot be found; it may have been moved or deleted.",
|
||||
"自你查看预览后,来源文件夹或已安装的副本又变了。请重新查看再更新": "The source folder or the installed copy changed after you opened the preview. Review it again before updating.",
|
||||
"已安装的副本被改过,更新会覆盖这些改动,需要你确认": "The installed copy was edited, and updating overwrites those edits; confirm to continue.",
|
||||
"这个技能太大,无法更新(文件数或总大小超过上限)": "This skill is too large to update (too many files or too many bytes).",
|
||||
"更新没有完成,已安装的副本保持原样:{detail}": "The update did not complete and the installed copy was left as it was: {detail}",
|
||||
"请先查看更新预览,再确认更新": "Open the update preview first, then confirm.",
|
||||
"版本固定未能保存:{detail}": "The version hold was not saved: {detail}",
|
||||
"不支持这种更新方式,没有改动。": "That update mode is not supported; nothing was changed.",
|
||||
"更新方式未能保存:{detail}": "The update mode was not saved: {detail}",
|
||||
"这不是一个已发布的版本号": "That is not a published version number",
|
||||
"这个版本没有发布更新内容": "This version has no published notes",
|
||||
"暂时取不到更新内容,请检查网络后重试": "What's new cannot be reached right now. Check the network and try again",
|
||||
|
||||
@@ -124,6 +124,9 @@ export const EN_REMOTE: Record<string, string> = {
|
||||
"查看并管理正在操作这台电脑的设备": "See and manage devices controlling this computer",
|
||||
"手机扫码即可进入这台电脑的 Studio": "Scan with your phone to open this computer's Studio",
|
||||
"远程连接已断开": "Remote connection ended",
|
||||
"刷新页面": "Refresh page",
|
||||
"这个页面不是最新版": "This page is not the latest version",
|
||||
"它是之前打开的旧版本,继续用可能出现莫名的报错。刷新一下就能换成最新版。": "It is an older build that was opened earlier, and may show confusing errors. Refreshing switches to the latest.",
|
||||
"这台电脑已停止网页控制。重新连接需要再次验证设备状态。": "This computer has stopped web control. Reconnecting will verify the device again.",
|
||||
"重新连接": "Reconnect",
|
||||
"这个浏览器版本太旧,不支持远程控制需要的加密。请改用较新版本的 Chrome、Edge、Safari 或 Firefox 打开。": "This browser is too old for the encryption remote control needs. Open it in a recent version of Chrome, Edge, Safari or Firefox.",
|
||||
|
||||
@@ -16,6 +16,8 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"用 {name} 打开": "Open with {name}",
|
||||
"回到会话顶部": "Back to top",
|
||||
"跳到最新": "Jump to latest",
|
||||
"回到会话顶部(Shift+点击:上一个展开的块)": "Back to top (Shift+click: previous expanded block)",
|
||||
"跳到最新(Shift+点击:下一个展开的块)": "Jump to latest (Shift+click: next expanded block)",
|
||||
"首字时间": "Time to first token",
|
||||
"关闭其他浏览器标签": "Close other browser tabs",
|
||||
"关闭右侧浏览器标签": "Close browser tabs to the right",
|
||||
@@ -189,6 +191,10 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"推理强度": "Reasoning effort",
|
||||
"子代理声明": "Subagent declarations",
|
||||
"{name} 的子代理声明": "Subagent declarations for {name}",
|
||||
"技能信息": "Skill information",
|
||||
"{name} 的技能信息": "Skill information for {name}",
|
||||
"许可": "License",
|
||||
"运行要求": "Requirements",
|
||||
"声明的模型": "Declared model",
|
||||
"声明的推理强度": "Declared reasoning effort",
|
||||
"声明的工具": "Declared tools",
|
||||
@@ -360,6 +366,11 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"agent 用来打开和操作网页的工具。该开关单独保存,不受命令行版本浏览器设置的影响。修改会重建运行时,任务运行期间无法变更。": "The tools the agent uses to open and operate web pages. This switch is stored on its own and is not affected by the command-line version's browser setting. Changing it rebuilds the runtime and cannot be done while a task is running.",
|
||||
"无法读取内置浏览器设置。": "Could not read the built-in browser setting.",
|
||||
"启用内置浏览器": "Enable the built-in browser",
|
||||
"乐观模式(效率优先)": "Optimistic mode (efficiency first)",
|
||||
"无法读取子代理写入范围设置。": "Could not read the sub-agent write scope setting.",
|
||||
"子代理的写入范围": "Sub-agent write scope",
|
||||
"后台子代理写文件时如何占用工作区。默认严格:没有声明 write_paths 的子代理占住整个工作区,其他写入要等它结束。修改会重建运行时,任务运行期间无法变更。": "How a background sub-agent holds the workspace while it writes. Strict by default: a sub-agent that names no write_paths holds the whole workspace and other writers wait for it. Changing it rebuilds the runtime and cannot be done while a task runs.",
|
||||
"没有声明 write_paths 的子代理不再占住整个工作区,只在写某个文件的那一刻占用该路径,所以可以和主会话、其他子代理并发。代价:它的 bash 和 MCP 写入不受路径围栏约束,只在事后观察;两边同时改同一个文件时,后写的一方会被拒绝。默认关闭(严格模式)。": "A sub-agent that names no write_paths no longer holds the whole workspace; it holds a path only while it writes that file, so it can run beside the main conversation and other sub-agents. The cost: its bash and MCP writes are not confined by a path fence and are only observed afterwards; if two sides edit the same file at once, the later write is refused. Off by default (strict mode).",
|
||||
"agent 可以打开网页、读取内容并操作页面;浏览器在第一次使用时才启动。关闭后这些工具不会出现在工具列表中": "The agent can open web pages, read them and operate them; the browser only starts the first time it is used. When off, these tools are not in the tool list",
|
||||
"当前项目的配置文件开启了它,此工作区仍会提供内置浏览器。": "This project's config file turns it on, so this workspace still has the built-in browser.",
|
||||
"当前项目的配置文件关闭了它,此工作区不会提供内置浏览器。": "This project's config file turns it off, so this workspace has no built-in browser.",
|
||||
@@ -387,6 +398,16 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"推理强度选「自动」时使用的档位。": "The level used when reasoning effort is set to Auto.",
|
||||
"{n} 个档位": "{n} levels",
|
||||
"{n} 个模型单独设置": "{n} models with their own levels",
|
||||
"按模型设置价格": "Price by model",
|
||||
"每百万 token 的单价。留空表示未知,用量页会如实显示「未知」,而不是 0;填 0 表示免费。缓存命中价留空时按输入价计算。": "Price per million tokens. Left empty it is unknown, and the usage page says unknown rather than 0; enter 0 for free. An empty cache-hit price is charged at the input price.",
|
||||
"{model} 的输入单价": "{model} input price",
|
||||
"{model} 的输出单价": "{model} output price",
|
||||
"{model} 的缓存命中单价": "{model} cache-hit price",
|
||||
"{model} 的货币": "{model} currency",
|
||||
"未知": "Unknown",
|
||||
"同输入价": "Same as input",
|
||||
"缓存命中": "Cache hit",
|
||||
"输入价和输出价需要都填写,才会保存这一行。": "Fill in both input and output to save this row.",
|
||||
"按模型设置限制": "Limits per model",
|
||||
"留空的模型沿用上面的值,灰字是它当前继承到的值;填写后只对该模型生效。":
|
||||
"A blank field follows the values above; the grey text is what that model inherits now. A value applies to that model only.",
|
||||
@@ -546,6 +567,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"确认(还有 {n} 个没答)": "Confirm ({n} left)",
|
||||
"确认": "Confirm",
|
||||
"先不选择,直接回复": "Skip the choice and reply instead",
|
||||
"{time} 后自动跳过这个问题,点任意处保留": "This question is skipped in {time}; click anywhere to keep it",
|
||||
"需要你决定": "Need your decision",
|
||||
"需要你补充信息": "Need more information from you",
|
||||
"MCP 服务器 {name} 请求信息": "MCP server {name} is asking for information",
|
||||
@@ -604,6 +626,9 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"两者都没声明 write_paths,各自按整个工作区处理": "neither declared write_paths, so each is treated as writing the whole workspace",
|
||||
"它们要写的路径重叠": "they want to write the same path",
|
||||
"上下文约 {total},已超过压缩阈值 {at},没有更多可折叠的内容。占用最多:{top}。{active}": "The context holds about {total}, over the {at} compaction threshold, and nothing more can be folded. Largest: {top}. {active}",
|
||||
"折叠后仍有 {n}:{top}": "{n} still held after the fold: {top}",
|
||||
"最大的一条是 {tool} 的结果 {n}": "the largest is a {tool} result, {n}",
|
||||
"最大的一条是 {role} 消息 {n}": "the largest is a {role} message, {n}",
|
||||
"正在进行的这一轮占 {n},折叠动不了它。": "The turn in progress holds {n} of it, and a fold cannot take it.",
|
||||
"用户消息": "User messages",
|
||||
"自动压缩暂缓,上次尝试没有完成:{why}": "Automatic compaction is paused, the last attempt did not finish: {why}",
|
||||
@@ -708,7 +733,66 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"安装失败": "Install failed",
|
||||
"无法读取 {v} 的发布信息": "Could not read the release details for {v}",
|
||||
"版本目录暂时连不上,可能是网络或代理问题。当前版本未被改动,稍后重试即可。": "The version index cannot be reached right now, likely a network or proxy problem. The running version was not touched; try again later.",
|
||||
"源文件夹有更新": "Source has changed",
|
||||
"来源文件夹里的内容比已安装的副本新:{path}": "The source folder has newer content than the installed copy: {path}",
|
||||
"副本被改过": "Copy was edited",
|
||||
"已安装的副本与安装时不同,来源文件夹没有变化:{path}": "The installed copy differs from what was installed; the source folder has not changed: {path}",
|
||||
"两边都改过": "Both changed",
|
||||
"已安装的副本和来源文件夹各自都改过:{path}": "The installed copy and the source folder have each changed: {path}",
|
||||
"源文件夹不见了": "Source folder is gone",
|
||||
"找不到来源文件夹:{path}": "The source folder cannot be found: {path}",
|
||||
"从源文件夹更新…": "Update from source folder…",
|
||||
"文件太大,这里不显示内容。请到来源文件夹里查看:{path}": "This file is too large to show here. Read it in the source folder: {path}",
|
||||
"不是文本文件,这里不显示内容。": "Not a text file, so its content is not shown here.",
|
||||
"(没有可显示的差异)": "(no difference to show)",
|
||||
"更新 {name}:来源文件夹有 {n} 处变化": "Update {name}: {n} change(s) in the source folder",
|
||||
"{name} 与来源文件夹的内容一致,没有可更新的内容": "{name} matches its source folder; there is nothing to update",
|
||||
"来源:{path}。更新后,已安装的副本会与来源文件夹完全一致。只有你点击“更新”才会执行,AI 不能触发。": "Source: {path}. After the update the installed copy is identical to the source folder. It runs only when you click Update; the AI cannot trigger it.",
|
||||
"已安装的副本在安装后被改过": "The installed copy was edited after it was installed",
|
||||
"更新会让副本与来源文件夹完全一致,覆盖这些本地改动。": "The update makes the copy identical to the source folder, overwriting those local edits.",
|
||||
"我知道会覆盖本地改动": "I understand this overwrites local edits",
|
||||
"以下文件不是说明文字,需要逐项展开看过": "These files are not plain instructions; open each one to read it",
|
||||
"还有 {n} 个文件没有展开": "{n} file(s) not opened yet",
|
||||
"以下文件是说明文字": "These files are plain instructions",
|
||||
"有 {n} 个文件在这里无法显示,我已在来源文件夹里看过": "{n} file(s) cannot be shown here; I have read them in the source folder",
|
||||
"替换「我的」里已安装的副本,所有项目均可使用": "Replaces the installed copy under Mine, available to every project",
|
||||
"重新查看": "Review again",
|
||||
"修改": "Changed",
|
||||
"{name} 的内容已与来源文件夹一致,只是安装记录还停在旧版本": "{name} already matches its source folder; only its install record is behind",
|
||||
"查看会话概览": "Open the session overview",
|
||||
"{h} 小时 {m} 分": "{h} h {m} min",
|
||||
"{s} 秒": "{s} s",
|
||||
"主代理": "Main agent",
|
||||
"会话标题": "Session title",
|
||||
"分类器": "Classifier",
|
||||
"目标评估": "Goal evaluation",
|
||||
"部分请求没有价格信息": "Some requests carried no price",
|
||||
"价格未知:{reason}": "Price unknown: {reason}",
|
||||
"价格未知": "Price unknown",
|
||||
"还没有计费的请求": "Nothing billed yet",
|
||||
"会话概览": "Session overview",
|
||||
"自本次打开此会话起;恢复或分叉会重新计数": "Since this session was opened in this run; resuming or forking counts again",
|
||||
"这个会话还没有计费的请求": "This session has no billed requests yet",
|
||||
"返回了用量与价格的模型响应": "Model responses that reported usage and a price",
|
||||
"Token": "Tokens",
|
||||
"输入 {a} · 输出 {b}": "In {a} · out {b}",
|
||||
"命中 ÷ (命中 + 未命中);不含服务商未区分的 {n} tokens": "hit ÷ (hit + miss); leaves out {n} tokens the provider did not split",
|
||||
"命中 ÷ (命中 + 未命中)": "hit ÷ (hit + miss)",
|
||||
"工作时长": "Work time",
|
||||
"给出最终回答的回合耗时之和,含审批与工具等待": "Sum of the turns that gave a final answer, approval and tool waits included",
|
||||
"费用": "Cost",
|
||||
"请求 · Token · 缓存 · 费用": "Requests · Tokens · Cache · Cost",
|
||||
"{v} 下载中断": "Downloading {v} was interrupted",
|
||||
"更新方式": "Update mode",
|
||||
"后台检查新版本": "Check for new versions in the background",
|
||||
"有新版本 {v},在设置的「版本」里查看": "Version {v} is available; see Settings, Version",
|
||||
"决定窗口自己做不做事:两种方式都不会替你安装新版本,重启始终由你决定。": "Chooses whether the window does anything on its own. Neither mode installs a new version for you; restarting is always your call.",
|
||||
"提醒": "Notify",
|
||||
"不会在后台联网查新版本。要更新时在上面的版本列表里自己下载并重启。这是默认。": "Nothing is checked in the background. To update, download and restart yourself from the version list above. This is the default.",
|
||||
"每天在后台读取一次版本目录,有更新的版本时在设置图标上显示一个小点。不会下载,也不会安装或重启。": "Reads the version catalog once a day in the background and shows a dot on the Settings icon when a newer release exists. It never downloads, installs or restarts.",
|
||||
"下载暂停了:连接长时间没有数据,已重连 {n} 次…": "Download paused: the connection delivered nothing for a while, reconnected {n} time(s)…",
|
||||
"{v} 下载卡住了": "Downloading {v} got stuck",
|
||||
"下载连接长时间没有数据,重连几次仍未恢复。当前版本未被改动,可以稍后重试,或下载完整安装包手动安装。": "The download connection delivered nothing for a long time and several reconnects did not help. The running version was not touched; try again later, or download the full installer.",
|
||||
"网络中断或超时。当前版本未被改动,可以重试。": "The connection dropped or timed out. The running version was not touched; you can try again.",
|
||||
"{v} 未通过签名校验": "{v} failed its signature check",
|
||||
"下载到的文件与发布签名不符,已丢弃,没有安装。多为下载损坏或网络被篡改,请重试;反复出现请反馈。": "The downloaded file does not match the release signature, so it was discarded and nothing was installed. This is usually a corrupted download or a tampered network; try again, and report it if it keeps happening.",
|
||||
@@ -894,6 +978,7 @@ export const EN_SETTINGS: Record<string, string> = {
|
||||
"超出一屏的命令输出与文件内容": "Command output and file contents longer than a screen",
|
||||
"压缩简报": "Compaction brief",
|
||||
"上下文压缩后,模型接下来依据的那份简报": "The brief the model works from after context is compacted",
|
||||
"收起并预览首句": "Folded, with first sentence",
|
||||
"进行中展开": "Open while running",
|
||||
"失败时展开": "Open on failure",
|
||||
"改动时展开": "Open on file change",
|
||||
|
||||
@@ -158,7 +158,10 @@ const SAID: Record<string, string> = {
|
||||
"backup.limit_reached": "备份数量已达上限,请先删除一份旧备份",
|
||||
"backup.consent_required": "有会在本机运行程序或改变密钥去向的项目,需要逐项确认后才能恢复",
|
||||
"workspace.limit_reached": "项目列表已满(32 个),请先移除一个项目再添加",
|
||||
"provider.bad_model_price": "{model} 的价格必须是不小于 0 的数字,并选择货币",
|
||||
"provider.model_prices_unlisted": "{model} 不在已选模型中,未保存价格",
|
||||
"workspace.changing_disabled": "这台服务器不支持切换工作区",
|
||||
"workspace.isolate_base_unknown": "没有找到这个分支,没有创建隔离副本",
|
||||
"settings.unknown_preset": "不存在该预设",
|
||||
"settings.unknown_model": "没有找到这个模型,默认模型没有改动",
|
||||
"settings.default_model_brokered": "这个窗格的默认模型由模型所在的那台机器决定,请在本机的窗格里设置",
|
||||
@@ -376,6 +379,7 @@ const SAID: Record<string, string> = {
|
||||
// ── 待送达:条目、队列、这份存档各自会拒 ─────────────────────────
|
||||
"inbox.not_found": "该条已不在待送达队列中",
|
||||
"job.not_running": "这个后台任务已经不在运行了",
|
||||
"job.stop_in_progress": "已经请求停止这个后台任务,它的进程还没有退出,仍在等待",
|
||||
"inbox.invalid_state": "该条当前状态不允许此操作",
|
||||
"inbox.paused": "待送达已暂停,请先恢复派发再进行操作",
|
||||
"inbox.capacity_items": "待送达条数已达上限,请先发送部分内容",
|
||||
@@ -387,7 +391,7 @@ const SAID: Record<string, string> = {
|
||||
"inbox.idempotency_conflict": "该提交标识已被使用,且当时的内容不同",
|
||||
|
||||
// ── 配置文件本身坏了,以及每一个写设置的面板被它挡住时 ─────────
|
||||
"config.unparsed": "配置文件无法读取,因此本次未保存",
|
||||
"config.unparsed": "配置文件无法读取,因此本次未保存。到「设置」里可以看到出错的位置并修复",
|
||||
"changes.path_outside_tree": "{path} 不在当前工作树中,无法查看其改动",
|
||||
"changes.diff_failed": "无法读取该文件的改动 —— git 未返回结果",
|
||||
"trajectory.unreadable": "无法读取本次运行的轨迹记录,因此无法确定其覆盖范围",
|
||||
@@ -401,6 +405,10 @@ const SAID: Record<string, string> = {
|
||||
"compaction.no_soft_limit": "本次请求未包含阈值,未做任何修改",
|
||||
"browser_tools.save_failed": "内置浏览器设置未能保存:{detail}",
|
||||
"browser_tools.no_enabled": "本次请求未说明开关状态,未做任何修改",
|
||||
"subagent_claims.editing_disabled": "这台服务器未开放子代理写入范围设置",
|
||||
"subagent_claims.save_failed": "子代理写入范围设置未能保存:{detail}",
|
||||
"subagent_claims.no_choice": "本次请求未说明选择,未做任何修改",
|
||||
"write.path_busy": "这个路径正被另一个并行的子代理或写入占用。本次写入未执行;请换一个路径,或等对方结束后再写",
|
||||
"display_currency.invalid": "不支持这个币种,只能选自动、CNY 或 USD:{detail}",
|
||||
"display_currency.runtime_changed": "运行时已更换,费用显示币种未保存,请重试",
|
||||
"display_currency.save_failed": "费用显示币种未能保存:{detail}",
|
||||
@@ -486,6 +494,7 @@ const SAID: Record<string, string> = {
|
||||
"workspace_roots.covered_by_primary": "这个文件夹已经在当前工作区里",
|
||||
"workspace_roots.too_broad": "这个文件夹范围太大(磁盘根目录,或包含你的主目录或 Reasonix 数据目录)",
|
||||
"workspace_roots.protected": "这个文件夹含有或位于密钥、凭据或 Reasonix 自己的数据目录里,不能授予写入权限",
|
||||
"workspace_roots.skill_store": "这里是 Reasonix 保存已安装技能的位置,不能授予写入权限。想让 AI 持续改进自己的技能:把技能的源文件夹用“登记”(register)方式安装,之后在源文件夹里修改。",
|
||||
"workspace_roots.not_owned": "这个文件夹不属于当前用户,不能授予写入权限",
|
||||
"workspace_roots.overlaps_write_root": "这个文件夹和已有的可写位置(临时目录、工具缓存或另一个额外文件夹)重叠",
|
||||
"provider.preset_unknown": "没有这个预设",
|
||||
@@ -517,6 +526,10 @@ const SAID: Record<string, string> = {
|
||||
"feedback.reply_limit": "这份反馈的回复次数已到上限,或回复太频繁了,请稍后再试",
|
||||
"feedback.not_replyable": "这份反馈现在不接收回复",
|
||||
"feedback.challenge_required": "反馈服务要求额外验证,请稍后再试",
|
||||
"feedback.account_required": "需要先登录账号",
|
||||
"feedback.link_conflict": "这台电脑已关联到另一个账号",
|
||||
"feedback.link_limit": "这个账号关联的电脑已达上限",
|
||||
"feedback.no_install": "这台电脑还没有发过反馈",
|
||||
|
||||
// ── 远程连接停下来问的那一句 ───────────────────────────────────
|
||||
"ask.not_found": "不存在该待回答的问题",
|
||||
@@ -532,7 +545,18 @@ const SAID: Record<string, string> = {
|
||||
// ── 版本:这个内核背后有没有一个可更新的 Studio ─────────────────
|
||||
"studio.no_install": "这个 Studio 不是安装版(从源码启动),没有可以查看或切换的版本",
|
||||
"studio.no_logs": "这个内核没有窗口外壳,没有可以指出的日志目录",
|
||||
"skills.update_bad_name": "这不是一个技能名",
|
||||
"skills.update_not_recorded": "这个技能没有安装来源的记录,无法从源文件夹更新",
|
||||
"skills.update_not_local": "这个技能不是从本机文件夹复制安装的,无法从源文件夹更新(链接或远程来源请重新安装)",
|
||||
"skills.update_source_missing": "找不到来源文件夹,可能被移动或删除了",
|
||||
"skills.update_plan_stale": "自你查看预览后,来源文件夹或已安装的副本又变了。请重新查看再更新",
|
||||
"skills.update_needs_confirm": "已安装的副本被改过,更新会覆盖这些改动,需要你确认",
|
||||
"skills.update_too_large": "这个技能太大,无法更新(文件数或总大小超过上限)",
|
||||
"skills.update_failed": "更新没有完成,已安装的副本保持原样:{detail}",
|
||||
"skills.update_plan_required": "请先查看更新预览,再确认更新",
|
||||
"studio.pin_rejected": "版本固定未能保存:{detail}",
|
||||
"studio.update_mode_invalid": "不支持这种更新方式,没有改动。",
|
||||
"studio.update_mode_save_failed": "更新方式未能保存:{detail}",
|
||||
"studio.notes_bad_version": "这不是一个已发布的版本号",
|
||||
"studio.notes_absent": "这个版本没有发布更新内容",
|
||||
"studio.notes_unreachable": "暂时取不到更新内容,请检查网络后重试",
|
||||
|
||||
@@ -26,6 +26,7 @@ import "./styles/sponsor.css";
|
||||
import "./styles/chart.css";
|
||||
import "./styles/versions.css";
|
||||
import "./styles/changedpill.css";
|
||||
import "./styles/askskip.css";
|
||||
import "./styles/pastekeep.css";
|
||||
import "./styles/ctxitems.css";
|
||||
import "./styles/sidepanel.css";
|
||||
@@ -41,6 +42,7 @@ import "./styles/gitgraph.css";
|
||||
import "./styles/modelmenu.css";
|
||||
import "./styles/runsignals.css";
|
||||
import "./styles/toasts.css";
|
||||
import "./styles/overview.css";
|
||||
import { App } from "./ui/App";
|
||||
import { SseHub } from "./port/hub";
|
||||
import { HttpError } from "./port/http_error";
|
||||
@@ -52,6 +54,7 @@ import { settled } from "./boot/gate";
|
||||
import { failureView, type FailureView } from "./port/cloud_failure";
|
||||
import { RemoteLinkError } from "./port/cloud_link";
|
||||
import { RemoteNotice } from "./ui/RemoteNotice";
|
||||
import { useStalePage } from "./ui/stale_page";
|
||||
import { onRemoteConnectionEnded, remoteConnectionEnded, openRemoteHome, remoteHub, signInAgain, type RemoteEnd } from "./port/cloud_remote";
|
||||
|
||||
const failureAction = (view: FailureView) => (view.action === "home" ? openRemoteHome : () => location.reload());
|
||||
@@ -63,11 +66,21 @@ function RemoteAwareApp({ hub, remote }: { hub: HubPort; remote: string | null }
|
||||
if (!remote) return;
|
||||
return onRemoteConnectionEnded(setEnded);
|
||||
}, [remote]);
|
||||
const stale = useStalePage(!!remote);
|
||||
const reauth = ended?.kind === "reauth";
|
||||
const specific = !reauth && ended?.failure ? failureView(ended.failure) : null;
|
||||
return (
|
||||
<>
|
||||
<App hub={hub} />
|
||||
{stale && !ended && (
|
||||
<RemoteNotice
|
||||
title={t("这个页面不是最新版")}
|
||||
body={t("它是之前打开的旧版本,继续用可能出现莫名的报错。刷新一下就能换成最新版。")}
|
||||
action={t("刷新页面")}
|
||||
info
|
||||
onAction={() => location.reload()}
|
||||
/>
|
||||
)}
|
||||
{ended && remote && (
|
||||
<RemoteNotice
|
||||
title={reauth ? t("需要重新登录") : specific?.title ?? t("远程连接已断开")}
|
||||
|
||||
@@ -99,6 +99,13 @@ export interface BrowserToolsSettings {
|
||||
path: string;
|
||||
}
|
||||
|
||||
// How a sub-agent that declared no write_paths holds its claim, as the user
|
||||
// file records it. A project file cannot set it, so there is one value.
|
||||
export interface SubagentClaimsSettings {
|
||||
optimistic: boolean;
|
||||
path: string;
|
||||
}
|
||||
|
||||
export interface AutoArchiveSettings {
|
||||
enabled: boolean;
|
||||
days: number;
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
// What holds the tokens of a prompt: the six classes by size, largest first,
|
||||
// and the biggest single messages. active is the running turn, which a fold
|
||||
// cannot take.
|
||||
export interface ContextComposition {
|
||||
total: number;
|
||||
at: number;
|
||||
active: number;
|
||||
parts: { name: string; tokens: number }[];
|
||||
largest?: { role: string; tool?: string; tokens: number; active?: boolean }[];
|
||||
}
|
||||
@@ -159,6 +159,15 @@ export interface FeedbackMine {
|
||||
profile?: FeedbackProfile | null;
|
||||
}
|
||||
|
||||
// One install an account holds, as the service lists it: the short id, when it
|
||||
// was linked, and whether it is this machine. The install's token never comes
|
||||
// with it.
|
||||
export interface FeedbackLinkedInstall {
|
||||
id: string;
|
||||
linkedAt: string;
|
||||
current: boolean;
|
||||
}
|
||||
|
||||
// The window a refusal came from, as the service names it. A refusal carries
|
||||
// these in its params; an identifier not listed here is still a window.
|
||||
export const FEEDBACK_LIMIT = {
|
||||
@@ -187,6 +196,10 @@ export const FEEDBACK_CODE = {
|
||||
replyLimit: "feedback.reply_limit",
|
||||
notReplyable: "feedback.not_replyable",
|
||||
challengeRequired: "feedback.challenge_required",
|
||||
accountRequired: "feedback.account_required",
|
||||
linkConflict: "feedback.link_conflict",
|
||||
linkLimit: "feedback.link_limit",
|
||||
noInstall: "feedback.no_install",
|
||||
offline: "feedback.offline",
|
||||
unavailable: "feedback.unavailable",
|
||||
internal: "feedback.internal",
|
||||
|
||||
@@ -442,6 +442,7 @@ export const SCRIPT: Beat[] = [
|
||||
projectionTokens: 31_200,
|
||||
coverageRequired: 12,
|
||||
coverageMissing: 2,
|
||||
remains: { total: 31_200, at: 160_000, active: 6_400, parts: [{ name: "tool_output", tokens: 14_800 }, { name: "reply", tokens: 9_100 }, { name: "user", tokens: 4_300 }], largest: [{ role: "tool", tool: "browser_read", tokens: 7_200 }] },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -74,3 +74,15 @@ describe("host capabilities", () => {
|
||||
expect(calls).toEqual([["GET", "/host/capabilities"]]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("closing a pane the kernel no longer has", () => {
|
||||
it("counts as closed, so a stale pane id never reaches the person as an error", async () => {
|
||||
answer(404, JSON.stringify({ code: "request.not_found", error: "no runtime named r1", params: { kind: "runtime", name: "r1" } }));
|
||||
await expect(new SseHub().close("r1")).resolves.toBeUndefined();
|
||||
});
|
||||
|
||||
it("still surfaces every other refusal", async () => {
|
||||
answer(500, JSON.stringify({ code: "internal", error: "teardown failed" }));
|
||||
await expect(new SseHub().close("r1")).rejects.toBeInstanceOf(HttpError);
|
||||
});
|
||||
});
|
||||
@@ -290,7 +290,12 @@ export class SseHub implements HubPort {
|
||||
}
|
||||
|
||||
async close(id: string) {
|
||||
await this.post<void>(`/runtimes/${encodeURIComponent(id)}/close`);
|
||||
try {
|
||||
await this.post<void>(`/runtimes/${encodeURIComponent(id)}/close`);
|
||||
} catch (e) {
|
||||
// A pane the kernel no longer holds is already closed.
|
||||
if (!(e instanceof HttpError && e.reason?.code === "request.not_found")) throw e;
|
||||
}
|
||||
this.ports.delete(id);
|
||||
this.settings.delete(id);
|
||||
}
|
||||
|
||||
@@ -243,7 +243,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
await this.newSession();
|
||||
}
|
||||
|
||||
async isolateWorkspace() {
|
||||
async isolateWorkspace(_base?: string) {
|
||||
await this.setWorkspace((this.state.workspaceRoot ?? "") + " (隔离副本)");
|
||||
}
|
||||
|
||||
@@ -299,7 +299,7 @@ export class MockPort extends MockWorkspace implements AgentPort {
|
||||
return `目标:${draft.trim()}\n\n约束:不改变现有行为。\n\n完成标准:相关测试通过。`;
|
||||
}
|
||||
|
||||
async complete(line: string, cursor: number): Promise<Completion> {
|
||||
async complete(line: string, cursor: number, _signal?: AbortSignal): Promise<Completion> {
|
||||
const before = line.slice(0, cursor);
|
||||
const at = before.lastIndexOf("@");
|
||||
if (at >= 0 && !/\s/.test(before.slice(at + 1)) && (at === 0 || /\s/.test(line[at - 1]))) {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { Adjudications, BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
|
||||
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings, UpdateMode } from "./port";
|
||||
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import { HttpError } from "./http_error";
|
||||
import { MockShell } from "./mock_shell";
|
||||
@@ -137,6 +137,17 @@ export class MockBoundary extends MockShell {
|
||||
return { ...this.browser };
|
||||
}
|
||||
|
||||
private claims: SubagentClaimsSettings = { optimistic: false, path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
async subagentClaims(): Promise<SubagentClaimsSettings> {
|
||||
return { ...this.claims };
|
||||
}
|
||||
|
||||
async saveSubagentClaims(optimistic: boolean): Promise<SubagentClaimsSettings> {
|
||||
this.claims = { ...this.claims, optimistic };
|
||||
return { ...this.claims };
|
||||
}
|
||||
|
||||
private archive: AutoArchiveSettings = { enabled: false, days: 30, defaultDays: 30, path: "/Users/you/.reasonix/config.toml" };
|
||||
|
||||
async autoArchive(): Promise<AutoArchiveSettings> {
|
||||
@@ -251,4 +262,8 @@ export class MockBoundary extends MockShell {
|
||||
this.broken = null;
|
||||
return { backup, problem: null };
|
||||
}
|
||||
|
||||
private mode: UpdateMode = "manual";
|
||||
async updateMode(): Promise<UpdateMode> { return this.mode; }
|
||||
async setUpdateMode(mode: UpdateMode): Promise<UpdateMode> { this.mode = mode; return mode; }
|
||||
}
|
||||
@@ -1,5 +1,5 @@
|
||||
import { download } from "./download";
|
||||
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, ScopeLayer, SkillCatalog, SkillEntry } from "./port";
|
||||
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, ScopeLayer, SkillCatalog, SkillEntry, SkillUpdatePlan } from "./port";
|
||||
import { MockMarket } from "./mock_market";
|
||||
|
||||
// The extension half of the fixture: skills, and the packages that bring them.
|
||||
@@ -22,6 +22,7 @@ export class MockExtensions extends MockMarket {
|
||||
{
|
||||
name: "release-notes", description: "从提交历史起草发布说明",
|
||||
scope: "global", path: "~/.reasonix/skills/release-notes/SKILL.md", enabled: false,
|
||||
license: "Apache-2.0", compatibility: "需要 git,并能访问网络", metadata: { author: "example-org", version: "1.0" },
|
||||
},
|
||||
{
|
||||
name: "deploy-runbook", slashName: "deploy-runbook", description: "只在你点名时跑的部署清单",
|
||||
@@ -71,6 +72,11 @@ export class MockExtensions extends MockMarket {
|
||||
// real store keys them by repository, which a browser fixture cannot do.
|
||||
protected localSkills = new Set<string>();
|
||||
|
||||
async planSkillUpdate(name: string): Promise<SkillUpdatePlan> {
|
||||
return { name, source: "/home/demo/skills/" + name, state: "source_changed", changes: [], overwritesLocal: false, planId: "demo" };
|
||||
}
|
||||
async applySkillUpdate(): Promise<void> {}
|
||||
|
||||
async setSkillEnabled(name: string, enabled: boolean, scope: ScopeLayer = "project") {
|
||||
const sk = this.skillList.find((x) => x.name === name);
|
||||
if (!sk) return;
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { HttpError } from "./http_error";
|
||||
import { MockCommit } from "./mock_commit";
|
||||
import { FEEDBACK_CODE, type FeedbackEnv, type FeedbackItem, type FeedbackMine, type FeedbackProfile, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
|
||||
import { FEEDBACK_CODE, type FeedbackEnv, type FeedbackImage, type FeedbackItem, type FeedbackLinkedInstall, type FeedbackMine, type FeedbackProfile, type FeedbackReceipt, type FeedbackReply, type FeedbackReplyReceipt, type FeedbackRequest } from "./feedback";
|
||||
|
||||
const LIMITS = { bodyBytes: 8192, nameChars: 40, contactChars: 120, images: 3, imageBytes: 2 << 20, uploadBytes: 10 << 20, replyBytes: 4096 };
|
||||
|
||||
@@ -80,6 +80,7 @@ const STATUS: Record<string, number> = {
|
||||
[FEEDBACK_CODE.tooLarge]: 413, [FEEDBACK_CODE.rateLimited]: 429, [FEEDBACK_CODE.disabled]: 503,
|
||||
[FEEDBACK_CODE.duplicate]: 409, [FEEDBACK_CODE.badToken]: 409, [FEEDBACK_CODE.offline]: 502, [FEEDBACK_CODE.unavailable]: 502, [FEEDBACK_CODE.internal]: 500, [FEEDBACK_CODE.busy]: 503, [FEEDBACK_CODE.imageMetadata]: 400,
|
||||
[FEEDBACK_CODE.replyLimit]: 429, [FEEDBACK_CODE.notReplyable]: 409, [FEEDBACK_CODE.challengeRequired]: 403,
|
||||
[FEEDBACK_CODE.accountRequired]: 401, [FEEDBACK_CODE.linkConflict]: 409, [FEEDBACK_CODE.linkLimit]: 409, [FEEDBACK_CODE.noInstall]: 409,
|
||||
};
|
||||
|
||||
// A fault is a code, optionally followed by "@" and the window it names:
|
||||
@@ -135,7 +136,10 @@ export class MockFeedback extends MockCommit {
|
||||
this.announced += 1;
|
||||
}
|
||||
|
||||
async replyFeedback(receipt: string, body: string): Promise<FeedbackReplyReceipt> {
|
||||
readonly replyImages: number[] = [];
|
||||
|
||||
async replyFeedback(receipt: string, body: string, images: FeedbackImage[] = []): Promise<FeedbackReplyReceipt> {
|
||||
this.replyImages.push(images.length);
|
||||
const code = fault(REPLY_FAULT);
|
||||
if (code === "feedback.invalid") {
|
||||
throw new HttpError(400, "invalid", { code, error: "invalid", params: { field: "body", reason: "empty" } });
|
||||
@@ -153,6 +157,33 @@ export class MockFeedback extends MockCommit {
|
||||
return { replyId, createdAt };
|
||||
}
|
||||
|
||||
// The demo keeps its links in the tab: signed-in is the same switch account() reads.
|
||||
private links: FeedbackLinkedInstall[] = [{ id: "ba9876543210", linkedAt: ago(9), current: false }];
|
||||
|
||||
private requireAccount(): void {
|
||||
if (!localStorage.getItem("reasonix.mock.account")) throw refusal(FEEDBACK_CODE.accountRequired);
|
||||
}
|
||||
|
||||
async feedbackLinks(): Promise<FeedbackLinkedInstall[]> {
|
||||
this.requireAccount();
|
||||
return this.links.map((l) => ({ ...l }));
|
||||
}
|
||||
|
||||
async linkFeedbackAccount(): Promise<void> {
|
||||
this.requireAccount();
|
||||
if (!this.links.some((l) => l.current)) this.links = [...this.links, { id: "0123456789ab", linkedAt: new Date().toISOString(), current: true }];
|
||||
}
|
||||
|
||||
async unlinkFeedbackAccount(): Promise<void> {
|
||||
this.requireAccount();
|
||||
this.links = this.links.filter((l) => !l.current);
|
||||
}
|
||||
|
||||
async unlinkFeedbackInstall(id: string): Promise<void> {
|
||||
this.requireAccount();
|
||||
this.links = this.links.filter((l) => l.id !== id);
|
||||
}
|
||||
|
||||
async feedbackSeen(receipt: string, upTo: number): Promise<void> {
|
||||
if (this.rows.some((i) => i.receipt === receipt)) this.seen.set(receipt, Math.max(this.seen.get(receipt) ?? 0, upTo));
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import type {
|
||||
ModelEffort,
|
||||
ModelLimit,
|
||||
ModelRate,
|
||||
Protocol,
|
||||
ProviderCheck,
|
||||
ProviderRemoval,
|
||||
@@ -178,6 +179,7 @@ export class MockProvider extends MockBoundary {
|
||||
defaultEffort: edit.defaultEffort,
|
||||
modelEfforts: edit.modelEfforts ? mockModelEfforts(edit.modelEfforts) : p.modelEfforts,
|
||||
modelLimits: edit.modelLimits ? mockModelLimits(edit.modelLimits) : p.modelLimits,
|
||||
modelPrices: edit.modelPrices ? mockModelPrices(edit.modelPrices, edit.models, p.modelPrices) : p.modelPrices,
|
||||
}
|
||||
: p,
|
||||
);
|
||||
@@ -223,3 +225,15 @@ function mockModelLimits(sent: Record<string, ModelLimit>): Record<string, Model
|
||||
const kept = Object.entries(sent).filter(([, v]) => v.contextWindow || v.maxOutputTokens);
|
||||
return kept.length ? Object.fromEntries(kept) : undefined;
|
||||
}
|
||||
|
||||
// A listed model sent null has none of its own; one left out of the answer keeps
|
||||
// what it had, as the kernel does.
|
||||
function mockModelPrices(sent: Record<string, ModelRate | null>, models: string[], was: Record<string, ModelRate> | undefined): Record<string, ModelRate> | undefined {
|
||||
const next: Record<string, ModelRate> = { ...(was ?? {}) };
|
||||
for (const model of models) {
|
||||
const rate = sent[model];
|
||||
if (rate) next[model] = rate;
|
||||
else delete next[model];
|
||||
}
|
||||
return Object.keys(next).length ? next : undefined;
|
||||
}
|
||||
@@ -84,10 +84,16 @@ export class MockShell extends MockExtensions {
|
||||
|
||||
// A path nobody has is the failure the pane has to render, so it is refused
|
||||
// here the way the kernel refuses it rather than quietly accepted.
|
||||
async saveShell(prefer: string, path: string): Promise<ShellSettings> {
|
||||
async saveShell(prefer: string, path: string, args: string[] = []): Promise<ShellSettings> {
|
||||
if (args.length > 0) {
|
||||
if (!/wsl\.exe$/i.test(path)) throw new Error(`这个 shell 用不了:${path} ${args.join(" ")}: did not run a command`);
|
||||
const argv = [path, ...args];
|
||||
this.sh = { ...this.sh, prefer, path, args, argv, effective: { ...GIT_BASH, name: "bash", path } };
|
||||
return { ...this.sh };
|
||||
}
|
||||
const found = (this.sh.options ?? []).find((o) => (path ? o.path === path : o.prefer === prefer));
|
||||
if (path && !found) throw new Error(`这个 shell 用不了:${path}: no such executable`);
|
||||
this.sh = { ...this.sh, prefer, path, effective: prefer === "auto" ? this.sh.auto : (found ?? this.sh.auto) };
|
||||
this.sh = { ...this.sh, prefer, path, args: undefined, argv: undefined, effective: prefer === "auto" ? this.sh.auto : (found ?? this.sh.auto) };
|
||||
return { ...this.sh };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -20,10 +20,10 @@ export type { MemoryEdit } from "./memory";
|
||||
export type { Money, UsageDay, UsageModel, UsageProvider, UsageQuery, UsageReport } from "./usage";
|
||||
import type { CompactionSettings, Completion, CompletionItem, ModelEntry, ModelMode, ModelPrice, RoleAssignments, RoleOverride } from "./model";
|
||||
import type { NetworkProbe, NetworkSettings } from "./network";
|
||||
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, WalletLine, WalletReading, PlanAction } from "./session";
|
||||
import type { ApprovalDefault, ApprovalMode, WorkspaceTrust, ApprovalVerdict, BrowserTab, Checkpoint, HistoryMessage, HostTodo, JobEntry, Preset, RewindPlan, RewindResult, RewindUndo, RewindScope, SessionEntry, SessionStatus, SessionUsage, SourceUsage, WalletLine, WalletReading, PlanAction } from "./session";
|
||||
import type { ContextBreakdown, ShellOption, ShellSettings } from "./shell";
|
||||
import type { SkillCatalog, SkillEntry } from "./skill";
|
||||
import type { StudioCrash, StudioLogs, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
|
||||
import type { SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan } from "./skill";
|
||||
import type { StudioCrash, StudioLogs, UpdateMode, UpdateProgress, VersionEntry, VersionHub, VersionNotes } from "./version";
|
||||
import type { ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo } from "./workspace";
|
||||
|
||||
// The port is one contract; its subjects each keep their own file, the way the
|
||||
@@ -34,19 +34,19 @@ export type { AccountState, AccountUser, ApprovalDefault, ApprovalMode, Approval
|
||||
HookCatalog, HookDryRun, HookEntry, HookEventInfo, HookSource, JobEntry, McpCatalog, McpDraft,
|
||||
McpDraftServer, McpEntry, McpInstallResult, McpInstallScope, McpLoad, McpRisk, McpTool, MemoryCatalog,
|
||||
MemoryEntry, ModelEntry, ModelMode, ModelPrice, NetworkProbe, NetworkSettings, Preset, RewindPlan,
|
||||
RewindResult, RewindUndo, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus,
|
||||
ShellOption, ShellSettings, SkillCatalog, SkillEntry, UpdateProgress, VersionEntry,
|
||||
StudioCrash, StudioLogs, VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo };
|
||||
RewindResult, RewindUndo, RewindScope, RoleAssignments, RoleOverride, ScopeLayer, SessionEntry, SessionStatus, SessionUsage, SourceUsage,
|
||||
ShellOption, ShellSettings, SkillCatalog, SkillEntry, SkillUpdateChange, SkillUpdatePlan, UpdateProgress, VersionEntry,
|
||||
StudioCrash, StudioLogs, UpdateMode, VersionHub, VersionNotes, WalletLine, WalletReading, ChangeDiff, CommitFile, CommitProposal, CommitRequest, CommitResult, StageRequest, WorkspaceBranch, WorkspaceBranches, WorkspaceChange, WorkspaceChanges, WorkspaceEntry, WorkspaceFile, WorkspaceFiles, WorkspaceGit, WorkspaceInfo };
|
||||
|
||||
import type { ExecutionGraphRead, TrajectoryRead, WireEvent } from "./wire";
|
||||
import type { PluginExport, PluginInstallRequest, PluginPackage, PluginPlan } from "./plugin";
|
||||
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote } from "./market";
|
||||
import type { Appearance, ThemeImport, ThemePack } from "./look";
|
||||
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, BrowserToolsSettings, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, BrowserToolsSettings, SubagentClaimsSettings, ConfigProblem, ConfigRepair, DisplayCurrencyMode, DisplayCurrencySettings, PermissionLists, PermissionRules, ProgressWatchSettings, SandboxSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
import type { Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
|
||||
export type { ModelEffort, ModelLimit, Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
|
||||
export type { ModelEffort, ModelLimit, ModelRate, Protocol, ProviderCheck, ProviderDraft, ProviderEdit, ProviderEntry, ProviderModelCheck, ProviderModelCheckRequest, ProviderProbe, ProviderSetup, ProviderRemoval, ProviderPreset, ProviderPresetInstall } from "./provider";
|
||||
import type { StoragePlan, StorageQuery, StorageState } from "./storage";
|
||||
import type { FeedbackEnv, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
|
||||
import type { FeedbackEnv, FeedbackImage, FeedbackLinkedInstall, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
|
||||
|
||||
export type * from "./plugin";
|
||||
export type * from "./market";
|
||||
@@ -169,7 +169,7 @@ export interface AgentPort {
|
||||
// What the line being typed can still become, asked once per keystroke. The
|
||||
// answer depends on the caret, so it cannot be cached into a static list the
|
||||
// way slash() is.
|
||||
complete(line: string, cursor: number): Promise<Completion>;
|
||||
complete(line: string, cursor: number, signal?: AbortSignal): Promise<Completion>;
|
||||
// The draft rewritten by the session's model, for the person to take or
|
||||
// leave; nothing is sent. Aborting the signal abandons the rewrite.
|
||||
refinePrompt(draft: string, signal?: AbortSignal): Promise<string>;
|
||||
@@ -180,6 +180,8 @@ export interface AgentPort {
|
||||
// row by default: the user answered for this folder, and two projects may
|
||||
// hold different skills of one name.
|
||||
setSkillEnabled(name: string, enabled: boolean, scope?: ScopeLayer, root?: string): Promise<void>;
|
||||
planSkillUpdate(name: string): Promise<SkillUpdatePlan>;
|
||||
applySkillUpdate(name: string, planId: string, overwriteLocal: boolean): Promise<void>;
|
||||
// Drops this project's exception so the skill inherits again.
|
||||
clearSkillOverride(name: string, root?: string): Promise<void>;
|
||||
plugins(): Promise<PluginPackage[]>;
|
||||
@@ -249,7 +251,7 @@ export interface AgentPort {
|
||||
// assembling and hands it to the shell tool, so a choice cannot reach a
|
||||
// runtime that is already up. Refused mid-turn, like every other rebuild.
|
||||
// An empty path leaves the executable to detection.
|
||||
saveShell(prefer: string, path: string): Promise<ShellSettings>;
|
||||
saveShell(prefer: string, path: string, args?: string[]): Promise<ShellSettings>;
|
||||
permissions(): Promise<PermissionRules>;
|
||||
// Replaces all three lists at once, then rebuilds — the gate is assembled
|
||||
// with the runtime, so a rule cannot reach one that is already up. Every rule
|
||||
@@ -271,6 +273,8 @@ export interface AgentPort {
|
||||
browserTools(): Promise<BrowserToolsSettings>;
|
||||
// Writes the user file, then rebuilds: the tools are bound while assembling.
|
||||
saveBrowserTools(enabled: boolean): Promise<BrowserToolsSettings>;
|
||||
subagentClaims(): Promise<SubagentClaimsSettings>;
|
||||
saveSubagentClaims(optimistic: boolean): Promise<SubagentClaimsSettings>;
|
||||
displayCurrency(): Promise<DisplayCurrencySettings>;
|
||||
// Applies live: the kernel announces it and every ledger rebinds; no rebuild.
|
||||
saveDisplayCurrency(mode: DisplayCurrencyMode): Promise<DisplayCurrencySettings>;
|
||||
@@ -370,6 +374,8 @@ export interface AgentPort {
|
||||
removeProvider(name: string): Promise<ProviderRemoval>;
|
||||
versions(): Promise<VersionHub>;
|
||||
pinVersion(version: string): Promise<void>;
|
||||
updateMode(): Promise<UpdateMode>;
|
||||
setUpdateMode(mode: UpdateMode): Promise<UpdateMode>;
|
||||
// A published release's notes. retry asks past the kernel's short memory of a failed fetch.
|
||||
versionNotes(version: string, retry?: boolean): Promise<VersionNotes>;
|
||||
// Where this install keeps its logs, and the crash files waiting there.
|
||||
@@ -419,9 +425,15 @@ export interface AgentPort {
|
||||
feedbackEnv(locale?: string): Promise<FeedbackEnv>;
|
||||
sendFeedback(req: FeedbackRequest): Promise<FeedbackReceipt>;
|
||||
myFeedback(): Promise<FeedbackMine>;
|
||||
replyFeedback(receipt: string, body: string): Promise<FeedbackReplyReceipt>;
|
||||
replyFeedback(receipt: string, body: string, images?: FeedbackImage[]): Promise<FeedbackReplyReceipt>;
|
||||
// Records that the thread was shown up to reply upTo, so those replies stop counting as new.
|
||||
feedbackSeen(receipt: string, upTo: number): Promise<void>;
|
||||
// Tying this install to the signed-in account is always the person's own
|
||||
// request. Linking changes no quota; unlinking only removes the join.
|
||||
feedbackLinks(): Promise<FeedbackLinkedInstall[]>;
|
||||
linkFeedbackAccount(): Promise<void>;
|
||||
unlinkFeedbackAccount(): Promise<void>;
|
||||
unlinkFeedbackInstall(id: string): Promise<void>;
|
||||
// Asks the window to raise the neutral "new reply" notification; the kernel
|
||||
// answers for the switches. Rejects where there is no window to raise it.
|
||||
announceFeedbackReply(): Promise<void>;
|
||||
@@ -429,7 +441,7 @@ export interface AgentPort {
|
||||
// Rebuilds the whole runtime against another folder. The conversation does
|
||||
// not come along, so the caller has to reload the transcript afterwards.
|
||||
setWorkspace(path: string): Promise<void>;
|
||||
isolateWorkspace(): Promise<void>;
|
||||
isolateWorkspace(base?: string): Promise<void>;
|
||||
// The native folder picker, or "" where there is none (a browser tab) or
|
||||
// when the user cancelled. Only the shell can open one.
|
||||
// The native folder picker: the chosen path, "" when the user cancelled, and
|
||||
|
||||
@@ -60,6 +60,9 @@ export interface ProviderEntry {
|
||||
// it inherits when it declares none.
|
||||
modelLimits?: Record<string, ModelLimit>;
|
||||
inheritedLimits?: Record<string, ModelLimit>;
|
||||
// Per model: the price per million tokens it declares for itself. A model
|
||||
// with none is absent, which means unknown, not zero.
|
||||
modelPrices?: Record<string, ModelRate>;
|
||||
// Removing the one in use would leave the session on a model that no longer
|
||||
// resolves, so the row offers no delete.
|
||||
inUse: boolean;
|
||||
@@ -247,6 +250,17 @@ export interface ProviderEdit {
|
||||
// The whole per-model answer for the listed models: a model left out, or sent
|
||||
// empty, inherits the connection's limits again. Omitted leaves every model alone.
|
||||
modelLimits?: Record<string, ModelLimit>;
|
||||
// The whole per-model answer for the listed models: a model sent null has no
|
||||
// price of its own again. Omitted leaves every model alone.
|
||||
modelPrices?: Record<string, ModelRate | null>;
|
||||
}
|
||||
|
||||
// One model's price per million tokens, zeros included: free is a price.
|
||||
export interface ModelRate {
|
||||
input: number;
|
||||
output: number;
|
||||
cacheHit: number;
|
||||
currency: string;
|
||||
}
|
||||
|
||||
// One model's own context window and output cap; an absent field inherits.
|
||||
|
||||
@@ -256,6 +256,7 @@ export interface SessionStatus {
|
||||
cacheHit: number;
|
||||
cacheMiss: number;
|
||||
sessionCostQuote?: import("./wire").CostQuote;
|
||||
sessionUsage?: SessionUsage;
|
||||
jobs?: JobEntry[];
|
||||
}
|
||||
|
||||
@@ -297,3 +298,29 @@ export interface SessionEntry {
|
||||
turns?: number;
|
||||
current?: boolean;
|
||||
}
|
||||
|
||||
// What one usage source has spent since this session was opened in this run.
|
||||
// cacheUnknown is prompt the endpoint did not split into cached and uncached.
|
||||
export interface SourceUsage {
|
||||
source: string;
|
||||
requests: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
cacheHit: number;
|
||||
cacheMiss: number;
|
||||
cacheUnknown?: number;
|
||||
cost: import("./wire").CostQuote;
|
||||
}
|
||||
|
||||
// The session's usage grouped by source, read from the ledger the cost total is.
|
||||
// workMs is the answered work time of turns that gave a final answer.
|
||||
export interface SessionUsage {
|
||||
requests: number;
|
||||
promptTokens: number;
|
||||
completionTokens: number;
|
||||
cacheHit: number;
|
||||
cacheMiss: number;
|
||||
cacheUnknown?: number;
|
||||
workMs: number;
|
||||
sources: SourceUsage[];
|
||||
}
|
||||
@@ -51,6 +51,10 @@ export interface ShellOption {
|
||||
export interface ShellSettings {
|
||||
prefer: string;
|
||||
path?: string;
|
||||
// Launcher arguments saved for path, and the exact command line that was run
|
||||
// to verify them when they are in force (absent when they fell back to auto).
|
||||
args?: string[];
|
||||
argv?: string[];
|
||||
effective: ShellOption;
|
||||
auto: ShellOption;
|
||||
options?: ShellOption[];
|
||||
|
||||
@@ -16,11 +16,43 @@ export interface SkillEntry {
|
||||
model?: string;
|
||||
effort?: string;
|
||||
allowedTools?: string[];
|
||||
// The specification's descriptive fields, as the skill wrote them.
|
||||
license?: string;
|
||||
compatibility?: string;
|
||||
metadata?: Record<string, string>;
|
||||
manual?: boolean;
|
||||
enabled: boolean;
|
||||
// Where the decision behind enabled lives, or absent when nothing overrides
|
||||
// what the skill itself declares.
|
||||
switchScope?: ScopeLayer;
|
||||
// Where an installed copy came from, and whether it still matches that folder.
|
||||
source?: SkillSource;
|
||||
}
|
||||
|
||||
// What updating an installed copy from its source folder would change, as the
|
||||
// person is shown it before agreeing.
|
||||
export interface SkillUpdateChange {
|
||||
path: string;
|
||||
kind: "added" | "changed" | "removed";
|
||||
code: boolean;
|
||||
diff?: string;
|
||||
truncated?: boolean;
|
||||
binary?: boolean;
|
||||
}
|
||||
|
||||
export interface SkillUpdatePlan {
|
||||
name: string;
|
||||
source: string;
|
||||
state: string;
|
||||
changes: SkillUpdateChange[];
|
||||
overwritesLocal: boolean;
|
||||
planId: string;
|
||||
}
|
||||
|
||||
export interface SkillSource {
|
||||
path: string;
|
||||
mode: string;
|
||||
state: "synced" | "source_changed" | "local_edited" | "diverged" | "source_missing" | "unknown";
|
||||
}
|
||||
|
||||
// implicit is the session-wide switch for model-initiated discovery. With it
|
||||
|
||||
@@ -47,11 +47,11 @@ export class SsePort extends SseWorkspace implements AgentPort {
|
||||
return r.models ?? [];
|
||||
}
|
||||
|
||||
complete(line: string, cursor: number) {
|
||||
complete(line: string, cursor: number, signal?: AbortSignal) {
|
||||
// The kernel renders the built-in verbs, and the language it would pick is
|
||||
// the process's. Only this window knows what it is drawing in.
|
||||
const q = new URLSearchParams({ line, cursor: String(cursor), lang: current() });
|
||||
return this.get<Completion>("/complete?" + q);
|
||||
return signal ? this.getUntil<Completion>("/complete?" + q, signal) : this.get<Completion>("/complete?" + q);
|
||||
}
|
||||
|
||||
async refinePrompt(draft: string, signal?: AbortSignal) {
|
||||
@@ -399,8 +399,8 @@ export class SsePort extends SseWorkspace implements AgentPort {
|
||||
return this.post("/workspace", { path });
|
||||
}
|
||||
|
||||
isolateWorkspace() {
|
||||
return this.post("/workspace", { isolate: true });
|
||||
isolateWorkspace(base?: string) {
|
||||
return this.post("/workspace", base ? { isolate: true, base } : { isolate: true });
|
||||
}
|
||||
|
||||
async openInEditor(): Promise<{ editor: string; root: string }> {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SseShell } from "./sse_shell";
|
||||
import type { Adjudications, BrowserToolsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
|
||||
import type { Adjudications, BrowserToolsSettings, SubagentClaimsSettings, ConfigProblem, ConfigRepair, PermissionLists, PermissionRules, SandboxSettings } from "./port";
|
||||
import type { AskTimeoutSettings, AutoArchiveSettings, ChangedFilesSettings, FoldWorkSettings, TurnNarrationSettings, DisplayCurrencyMode, DisplayCurrencySettings, ProgressWatchSettings, WorkspaceRoots, ReceiptSettings } from "./boundary";
|
||||
|
||||
// Where the agent may reach: the permission rules a call is matched against and
|
||||
@@ -41,6 +41,12 @@ export class SseBoundary extends SseShell {
|
||||
saveBrowserTools(enabled: boolean) {
|
||||
return this.post0<BrowserToolsSettings>("/browser-tools", { enabled });
|
||||
}
|
||||
subagentClaims() {
|
||||
return this.get<SubagentClaimsSettings>("/subagent-claims");
|
||||
}
|
||||
saveSubagentClaims(optimistic: boolean) {
|
||||
return this.post0<SubagentClaimsSettings>("/subagent-claims", { optimistic });
|
||||
}
|
||||
displayCurrency() {
|
||||
return this.get<DisplayCurrencySettings>("/display-currency");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { SseHttp } from "./sse_http";
|
||||
|
||||
class Probe extends SseHttp {
|
||||
constructor() {
|
||||
super("http://kernel.test");
|
||||
}
|
||||
read<T>(path: string, signal: AbortSignal) {
|
||||
return this.getUntil<T>(path, signal);
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => vi.restoreAllMocks());
|
||||
|
||||
it("passes the caller's signal to the request and never shares it with another read", async () => {
|
||||
const signals: (AbortSignal | undefined)[] = [];
|
||||
vi.stubGlobal("fetch", vi.fn(async (_url: string, init?: RequestInit) => {
|
||||
signals.push(init?.signal ?? undefined);
|
||||
return new Response(JSON.stringify({ ok: true }), { status: 200 });
|
||||
}));
|
||||
const a = new AbortController();
|
||||
const b = new AbortController();
|
||||
const probe = new Probe();
|
||||
await Promise.all([probe.read("/complete?line=/", a.signal), probe.read("/complete?line=/", b.signal)]);
|
||||
expect(signals).toEqual([a.signal, b.signal]);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SseLook } from "./sse_look";
|
||||
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote, PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, ScopeLayer, SkillCatalog } from "./port";
|
||||
import type { MarketDetail, MarketList, MarketOwnRequest, MarketPackage, MarketPlan, MarketPublished, MarketQuery, MarketRequest, MarketSubmission, MarketVote, PluginExport, PluginInstallRequest, PluginPackage, PluginPlan, ScopeLayer, SkillCatalog, SkillUpdatePlan } from "./port";
|
||||
import { rootQuery } from "./sse_http";
|
||||
import { download } from "./download";
|
||||
import { host } from "./host";
|
||||
@@ -32,6 +32,12 @@ export class SseExtensions extends SseLook {
|
||||
setSkillEnabled(name: string, enabled: boolean, scope: ScopeLayer = "project", root?: string) {
|
||||
return this.post("/skills/enabled", { name, enabled, scope, root });
|
||||
}
|
||||
planSkillUpdate(name: string) {
|
||||
return this.post0<SkillUpdatePlan>("/skills/" + encodeURIComponent(name) + "/update/plan", {});
|
||||
}
|
||||
applySkillUpdate(name: string, planId: string, overwriteLocal: boolean) {
|
||||
return this.post("/skills/" + encodeURIComponent(name) + "/update/apply", { planId, overwriteLocal });
|
||||
}
|
||||
clearSkillOverride(name: string, root?: string) {
|
||||
return this.post("/skills/enabled", { name, clear: true, scope: "project", root });
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { SseBackup } from "./sse_backup";
|
||||
import type { FeedbackEnv, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
|
||||
import type { FeedbackEnv, FeedbackImage, FeedbackLinkedInstall, FeedbackMine, FeedbackReceipt, FeedbackReplyReceipt, FeedbackRequest } from "./feedback";
|
||||
|
||||
export class SseFeedback extends SseBackup {
|
||||
feedbackEnv(locale?: string) {
|
||||
@@ -11,8 +11,8 @@ export class SseFeedback extends SseBackup {
|
||||
myFeedback() {
|
||||
return this.get<FeedbackMine>("/feedback/mine");
|
||||
}
|
||||
replyFeedback(receipt: string, body: string) {
|
||||
return this.post0<FeedbackReplyReceipt>("/feedback/" + encodeURIComponent(receipt) + "/reply", { body });
|
||||
replyFeedback(receipt: string, body: string, images: FeedbackImage[] = []) {
|
||||
return this.post0<FeedbackReplyReceipt>("/feedback/" + encodeURIComponent(receipt) + "/reply", images.length > 0 ? { body, images } : { body });
|
||||
}
|
||||
announceFeedbackReply() {
|
||||
return this.post("/notifications/feedback-reply", {});
|
||||
@@ -20,4 +20,16 @@ export class SseFeedback extends SseBackup {
|
||||
feedbackSeen(receipt: string, upTo: number) {
|
||||
return this.post("/feedback/" + encodeURIComponent(receipt) + "/seen", { upTo });
|
||||
}
|
||||
async feedbackLinks() {
|
||||
return (await this.get<{ installs: FeedbackLinkedInstall[] }>("/feedback/links")).installs;
|
||||
}
|
||||
linkFeedbackAccount() {
|
||||
return this.post("/feedback/link");
|
||||
}
|
||||
unlinkFeedbackAccount() {
|
||||
return this.del("/feedback/link");
|
||||
}
|
||||
unlinkFeedbackInstall(id: string) {
|
||||
return this.del("/feedback/links/" + encodeURIComponent(id));
|
||||
}
|
||||
}
|
||||
@@ -138,6 +138,14 @@ export class SseHttp {
|
||||
return slot.next as Promise<T>;
|
||||
}
|
||||
|
||||
// A read the caller can abandon: it is not shared with another caller's, so
|
||||
// abandoning it frees its connection instead of leaving it to run out.
|
||||
protected async getUntil<T>(path: string, signal: AbortSignal): Promise<T> {
|
||||
const res = await fetch(this.base + path, { credentials: "same-origin", signal });
|
||||
if (!res.ok) await SseHttp.fail(path, res);
|
||||
return (await res.json()) as T;
|
||||
}
|
||||
|
||||
private startRead<T>(path: string): Promise<T> {
|
||||
const flight = this.fetchRead<T>(path);
|
||||
const slot: ReadSlot = { flight };
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { SseHook } from "./sse_hook";
|
||||
import type { NetworkProbe, NetworkSettings } from "./port";
|
||||
import type { NetworkProbe, NetworkSettings, UpdateMode } from "./port";
|
||||
import { SseHttp } from "./sse_http";
|
||||
|
||||
// How the kernel reaches the outside, and what probing that found.
|
||||
export class SseNetwork extends SseHook {
|
||||
@@ -21,4 +22,20 @@ export class SseNetwork extends SseHook {
|
||||
const r = await this.post0<{ probes?: NetworkProbe[] }>("/network/diagnose");
|
||||
return r.probes ?? [];
|
||||
}
|
||||
async updateMode(): Promise<UpdateMode> {
|
||||
const res = await fetch("/studio/update-mode", { credentials: "same-origin" });
|
||||
if (!res.ok) await SseHttp.fail("/studio/update-mode", res);
|
||||
return ((await res.json()) as { mode: UpdateMode }).mode;
|
||||
}
|
||||
|
||||
async setUpdateMode(mode: UpdateMode): Promise<UpdateMode> {
|
||||
const res = await fetch("/studio/update-mode", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
credentials: "same-origin",
|
||||
body: JSON.stringify({ mode }),
|
||||
});
|
||||
if (!res.ok) await SseHttp.fail("/studio/update-mode", res);
|
||||
return ((await res.json()) as { mode: UpdateMode }).mode;
|
||||
}
|
||||
}
|
||||
@@ -18,7 +18,7 @@ export class SseShell extends SseExtensions {
|
||||
// post0 for the same reason setContextWindow uses it: a refusal here carries
|
||||
// a code, and hand-rolling the fetch threw it away — "this server does not
|
||||
// let you set the shell" reached the panel in the kernel's English.
|
||||
saveShell(prefer: string, path: string) {
|
||||
return this.post0<ShellSettings>("/shell", { prefer, path });
|
||||
saveShell(prefer: string, path: string, args?: string[]) {
|
||||
return this.post0<ShellSettings>("/shell", args && args.length > 0 ? { prefer, path, args } : { prefer, path });
|
||||
}
|
||||
}
|
||||
@@ -23,6 +23,10 @@ export interface WithdrawnNote {
|
||||
|
||||
// err rides alongside the data: an unreachable catalog must not hide which
|
||||
// version is running.
|
||||
// What this machine does about new releases on its own. Neither mode installs
|
||||
// anything: notify only reads the catalog and says when a release is newer.
|
||||
export type UpdateMode = "manual" | "notify";
|
||||
|
||||
export interface VersionHub {
|
||||
current: string;
|
||||
pinned: string;
|
||||
@@ -48,6 +52,7 @@ export interface UpdateProgress {
|
||||
total: number;
|
||||
err?: string;
|
||||
code?: string;
|
||||
stalls?: number;
|
||||
delta_skipped?: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import type { ContextComposition } from "./context_composition";
|
||||
import type { SurfaceImage, SurfaceLocation, SurfaceFact, SurfaceDataset } from "./surface-rich";
|
||||
export type { SurfaceImage, SurfaceLocation, SurfaceFact, SurfaceDataset, SurfaceSeries } from "./surface-rich";
|
||||
// Mirrors internal/contract/eventwire. Field names and kind strings must match exactly;
|
||||
@@ -17,6 +18,7 @@ export type Kind =
|
||||
| "turn_phase"
|
||||
| "approval_request"
|
||||
| "ask_request"
|
||||
| "ask_engaged"
|
||||
| "turn_done"
|
||||
| "compaction_started"
|
||||
| "compaction_progress"
|
||||
@@ -60,8 +62,7 @@ export interface Profile {
|
||||
export interface Execution {
|
||||
kind?: string;
|
||||
shell?: string;
|
||||
/** PowerShell 5.1 and 7 are two different languages to write a command in,
|
||||
* and the shell name alone cannot say which one answered. */
|
||||
/** PowerShell 5.1 and 7 are different languages; the shell name alone cannot say which answered. */
|
||||
shellVersion?: string;
|
||||
platform?: string;
|
||||
/** False on Windows PowerShell 5.1, where `&&` is a syntax error. */
|
||||
@@ -70,8 +71,7 @@ export interface Execution {
|
||||
failurePhase?: string;
|
||||
exitCode?: number;
|
||||
outputTail?: string;
|
||||
/** The command with its leading environment assignments dropped, when
|
||||
* dropping them changed it. What a row names; never what authorised it. */
|
||||
/** The command without its leading environment assignments, when that changed it; never what authorised it. */
|
||||
subject?: string;
|
||||
mutationRisk?: string;
|
||||
verification?: string;
|
||||
@@ -303,6 +303,7 @@ export interface Ask {
|
||||
id: string;
|
||||
questions: AskQuestion[];
|
||||
origin?: AskOrigin;
|
||||
skipInMs?: number;
|
||||
}
|
||||
|
||||
export interface Guardian {
|
||||
@@ -516,22 +517,21 @@ export interface Compaction {
|
||||
trigger?: string;
|
||||
messages?: number;
|
||||
summary?: string;
|
||||
// What the fold cost, and what the digest kept of it. coverageRequired counts
|
||||
// the changes and failures the folded work produced; coverageMissing is how
|
||||
// many the digest did not carry.
|
||||
// What the fold cost; coverageRequired counts the changes and failures it
|
||||
// folded and coverageMissing how many the digest did not carry.
|
||||
sourceTokens?: number;
|
||||
projectionTokens?: number;
|
||||
coverageRequired?: number;
|
||||
coverageMissing?: number;
|
||||
// The host wrote the dropped facts in itself, because the digest did not.
|
||||
coverageBackstopped?: boolean;
|
||||
// Which threshold sent this fold and how big it is. "Reached a threshold"
|
||||
// is all the card can say without them, and a fold at 16% of the declared
|
||||
// window is exactly the one that needs saying.
|
||||
// Which threshold sent this fold and how big it is.
|
||||
boundary?: string;
|
||||
triggerTokens?: number;
|
||||
// Why a fold that installed nothing did not; the host's class, not a sentence.
|
||||
code?: string;
|
||||
// What the prompt still holds when a fold left a large share of the window.
|
||||
remains?: ContextComposition;
|
||||
}
|
||||
|
||||
export interface StreamAttempt {
|
||||
|
||||
@@ -35,6 +35,9 @@ export interface WorkspaceGit {
|
||||
added: number;
|
||||
removed: number;
|
||||
untracked: number;
|
||||
// Why a folder that carries a .git has no repository: git refused it, or did not answer.
|
||||
absent?: "refused" | "unavailable";
|
||||
detail?: string;
|
||||
}
|
||||
|
||||
// GET /workspace/git/log: the newest commits of every branch, newest first. A
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import { afterEach, beforeEach, expect, it, vi } from "vitest";
|
||||
import { initialState as initial, reduce } from "./session";
|
||||
import type { Item, SessionEvent } from "./session";
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => vi.useRealTimers());
|
||||
|
||||
const ask = (skipInMs?: number): SessionEvent => ({ kind: "ask_request", ask: { id: "a1", questions: [], skipInMs } });
|
||||
const card = (s: typeof initial) => s.items.find((i: Item): i is Extract<Item, { t: "ask" }> => i.t === "ask")!;
|
||||
|
||||
it("stamps the deadline from the wait the kernel announced", () => {
|
||||
vi.setSystemTime(1_000_000);
|
||||
expect(card(reduce(initial, ask(30_000))).skipAt).toBe(1_030_000);
|
||||
});
|
||||
|
||||
it("leaves a question that is never skipped without a deadline", () => {
|
||||
expect(card(reduce(initial, ask())).skipAt).toBeUndefined();
|
||||
});
|
||||
|
||||
it("moves the same card's deadline when the question is announced again, and clears it when taken over", () => {
|
||||
vi.setSystemTime(1_000_000);
|
||||
let s = reduce(initial, ask(30_000));
|
||||
vi.setSystemTime(1_010_000);
|
||||
s = reduce(s, ask(20_000));
|
||||
expect(s.items.filter((i) => i.t === "ask")).toHaveLength(1);
|
||||
expect(card(s).skipAt).toBe(1_030_000);
|
||||
s = reduce(s, { kind: "ask_engaged", ask: { id: "a1", questions: [] } });
|
||||
expect(s.items.filter((i) => i.t === "ask")).toHaveLength(1);
|
||||
expect(card(s).skipAt).toBeUndefined();
|
||||
});
|
||||
@@ -0,0 +1,40 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import type { CostQuote } from "../port/wire";
|
||||
import { cacheRate, costSay } from "./overview";
|
||||
|
||||
const quote = (over: Partial<CostQuote>): CostQuote => ({ original: { amount: "12.5", currency: "CNY" }, estimated: false, costComplete: false, ...over } as CostQuote);
|
||||
|
||||
describe("the cache rate", () => {
|
||||
it("is the share of split prompt that was cached, not an average of rates", () => {
|
||||
expect(cacheRate(900, 100)).toBeCloseTo(0.9);
|
||||
expect(cacheRate(2300, 700)).toBeCloseTo(2300 / 3000);
|
||||
});
|
||||
|
||||
it("leaves prompt the endpoint did not split out of the denominator", () => {
|
||||
expect(cacheRate(2300, 700 + 5000, 5000)).toBeCloseTo(2300 / 3000);
|
||||
});
|
||||
|
||||
it("says nothing when no request was split", () => {
|
||||
expect(cacheRate(0, 5000, 5000)).toBeNull();
|
||||
expect(cacheRate(0, 0)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("saying a cost as its coverage allows", () => {
|
||||
it("gives an exact figure only for a complete cost", () => {
|
||||
expect(costSay(quote({ coverage: "complete" }))).toEqual({ kind: "exact", amount: 12.5, currency: "CNY" });
|
||||
});
|
||||
|
||||
it("gives a floor, not a figure, for a partly priced one", () => {
|
||||
expect(costSay(quote({ coverage: "partial" }))).toEqual({ kind: "atLeast", amount: 12.5, currency: "CNY" });
|
||||
});
|
||||
|
||||
it("gives no amount for an unpriced one, and keeps the reason", () => {
|
||||
expect(costSay(quote({ coverage: "incomplete", incompleteReason: "no_rate" }))).toEqual({ kind: "unknown", reason: "no_rate" });
|
||||
});
|
||||
|
||||
it("says nothing was billed for none, or for no quote at all", () => {
|
||||
expect(costSay(quote({ coverage: "none" }))).toEqual({ kind: "none" });
|
||||
expect(costSay(undefined)).toEqual({ kind: "none" });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,41 @@
|
||||
import type { CostQuote } from "../port/wire";
|
||||
import type { SessionUsage, SourceUsage } from "../port/session";
|
||||
import { quoteAmount } from "./usage";
|
||||
|
||||
// What a session overview says is only worth saying with its definition beside
|
||||
// it, so the arithmetic lives here, once, and the card words it.
|
||||
|
||||
/** hit / (hit + miss - unknown): prompt the endpoint did not split into cached
|
||||
* and uncached is out of the denominator, so it cannot pull the rate down.
|
||||
* Null when nothing was split at all. */
|
||||
export function cacheRate(hit: number, miss: number, unknown = 0): number | null {
|
||||
const known = hit + miss - unknown;
|
||||
if (!(known > 0)) return null;
|
||||
return Math.min(1, hit / known);
|
||||
}
|
||||
|
||||
export type CostSay =
|
||||
| { kind: "exact"; amount: number; currency: string }
|
||||
| { kind: "atLeast"; amount: number; currency: string }
|
||||
| { kind: "unknown"; reason: string }
|
||||
| { kind: "none" };
|
||||
|
||||
/** The cost as its coverage allows it to be said: an exact figure only when every
|
||||
* request carried a price fact, a floor when some did, and nothing otherwise. */
|
||||
export function costSay(quote: CostQuote | undefined): CostSay {
|
||||
const coverage = quote?.coverage ?? (quote?.costComplete ? "complete" : "none");
|
||||
const amount = quoteAmount(quote);
|
||||
const currency = quote?.selected?.currency || quote?.original.currency || "";
|
||||
if (coverage === "complete" && amount !== undefined) return { kind: "exact", amount, currency };
|
||||
if (coverage === "partial" && amount !== undefined) return { kind: "atLeast", amount, currency };
|
||||
if (coverage === "incomplete") return { kind: "unknown", reason: quote?.incompleteReason ?? "" };
|
||||
return { kind: "none" };
|
||||
}
|
||||
|
||||
export function sourceTokens(s: Pick<SourceUsage, "promptTokens" | "completionTokens">): number {
|
||||
return s.promptTokens + s.completionTokens;
|
||||
}
|
||||
|
||||
export function sessionTokens(u: Pick<SessionUsage, "promptTokens" | "completionTokens">): number {
|
||||
return u.promptTokens + u.completionTokens;
|
||||
}
|
||||
@@ -200,11 +200,11 @@ export function setLiveGraceMinutes(minutes: number): void {
|
||||
// that failed; "changed" opens only what wrote a file. A block the reader opened
|
||||
// or closed keeps that choice.
|
||||
export type Fold = "thinking" | "activity" | "steps" | "output" | "compaction";
|
||||
export type FoldMode = "folded" | "live" | "failed" | "changed" | "open";
|
||||
export type FoldMode = "folded" | "peek" | "live" | "failed" | "changed" | "open";
|
||||
export type FoldModes = Readonly<Record<Fold, FoldMode>>;
|
||||
|
||||
export const FOLD_CHOICES: Readonly<Record<Fold, readonly FoldMode[]>> = {
|
||||
thinking: ["folded", "live", "open"],
|
||||
thinking: ["folded", "peek", "live", "open"],
|
||||
activity: ["folded", "live", "changed", "open"],
|
||||
steps: ["folded", "failed", "changed", "open"],
|
||||
output: ["folded", "open"],
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
import type { Item, SessionState } from "./session_types";
|
||||
import type { Ask, AskQuestion, DecisionReceipt } from "../port/wire";
|
||||
import { IDLE, RUNNING } from "./chip";
|
||||
import { nextId } from "./ids";
|
||||
|
||||
// The id the kernel correlates an answer by, which is what makes two frames one
|
||||
// prompt. Local item ids cannot: a replay mints a new one every time.
|
||||
@@ -123,3 +124,26 @@ export function askFromCall(id: string, args: string | undefined): Ask | null {
|
||||
}
|
||||
return { id, questions };
|
||||
}
|
||||
|
||||
// askAnnounced folds an ask_request. One already open only moves its deadline:
|
||||
// the same question announced again says how long is left, or that it is no
|
||||
// longer skipped.
|
||||
export function askAnnounced(s: SessionState, ask: Ask): SessionState {
|
||||
const skipAt = ask.skipInMs ? Date.now() + ask.skipInMs : undefined;
|
||||
const shown = s.items.findIndex((i) => i.t === "ask" && i.ask.id === ask.id && !i.surfaceExpired);
|
||||
if (shown < 0) return prompted(s, "等待你回答", { t: "ask", id: nextId(), ask, skipAt });
|
||||
const was = s.items[shown] as Extract<Item, { t: "ask" }>;
|
||||
if (was.skipAt === skipAt || was.answered !== undefined) return s;
|
||||
const items = s.items.slice();
|
||||
items[shown] = { ...was, skipAt };
|
||||
return { ...s, items };
|
||||
}
|
||||
|
||||
// askEngaged stops the countdown of a question the person has taken over.
|
||||
export function askEngaged(s: SessionState, id: string): SessionState {
|
||||
const at = s.items.findIndex((i) => i.t === "ask" && i.ask.id === id && i.skipAt !== undefined);
|
||||
if (at < 0) return s;
|
||||
const items = s.items.slice();
|
||||
items[at] = { ...(items[at] as Extract<Item, { t: "ask" }>), skipAt: undefined };
|
||||
return { ...s, items };
|
||||
}
|
||||
@@ -16,7 +16,7 @@ import { splitProviderSearch } from "./providersearch";
|
||||
|
||||
export type { Item, Metrics, PlanStep, RememberedFact, RuntimeNotice, SessionState, TodoStatus, TurnTerminal, Waiting };
|
||||
export { currentStep, stepDone, stepLabel };
|
||||
import { askFromCall, promptOpen, prompted, sealByReceipt } from "./prompts";
|
||||
import { askAnnounced, askEngaged, askFromCall, promptOpen, prompted, sealByReceipt } from "./prompts";
|
||||
import { nameTurnStart, othersSteer } from "./turn_start";
|
||||
import { appendText, foldMessage, sealSay } from "./say";
|
||||
import { nextId } from "./ids";
|
||||
@@ -456,8 +456,10 @@ function apply(s: SessionState, ev: SessionEvent): SessionState {
|
||||
return ev.approval ? prompted(s, "等待批准", { t: "approval", id: nextId(), a: ev.approval }) : s;
|
||||
|
||||
case "ask_request":
|
||||
if (!ev.ask || s.items.some(i => i.t === "ask" && i.ask.id === ev.ask!.id && !i.surfaceExpired)) return s;
|
||||
return prompted(s, "等待你回答", { t: "ask", id: nextId(), ask: ev.ask });
|
||||
return ev.ask ? askAnnounced(s, ev.ask) : s;
|
||||
|
||||
case "ask_engaged":
|
||||
return ev.ask ? askEngaged(s, ev.ask.id) : s;
|
||||
|
||||
case "compaction_started":
|
||||
return { ...s, items: [...s.items, { t: "compaction", id: nextId(), c: ev.compaction ?? {}, done: false }] };
|
||||
|
||||
@@ -40,7 +40,7 @@ export type Item =
|
||||
// kernel recorded they said, since this screen never saw the selection.
|
||||
// recorded is the call's result when the card is rebuilt from history: the
|
||||
// record keeps what the model was told, not which options were pressed.
|
||||
| { t: "ask"; id: string; ask: Ask; answered?: string[][]; answeredElsewhere?: boolean; timedOut?: boolean; by?: Via | "window"; said?: string; recorded?: string; surfaceValues?: Record<string, unknown>; surfaceExpired?: boolean }
|
||||
| { t: "ask"; id: string; ask: Ask; skipAt?: number; answered?: string[][]; answeredElsewhere?: boolean; timedOut?: boolean; by?: Via | "window"; said?: string; recorded?: string; surfaceValues?: Record<string, unknown>; surfaceExpired?: boolean }
|
||||
| { t: "compaction"; id: string; c: Compaction; done: boolean }
|
||||
| { t: "remember"; id: string; m: RememberedFact; forgotten?: boolean }
|
||||
| { t: "receipt"; id: string; r: Receipt }
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import type { UpdateMode, VersionHub } from "../port/version";
|
||||
import { readUpdateAvailable, resetUpdateWatch, startUpdateWatch } from "./updatewatch";
|
||||
|
||||
const hub = (over: Partial<VersionHub> = {}): VersionHub => ({
|
||||
current: "2.20.0", pinned: "", stalePin: false, latest: "2.21.0", newer: true, versions: [], ...over,
|
||||
});
|
||||
|
||||
function port(mode: UpdateMode, read: () => Promise<VersionHub> = () => Promise.resolve(hub())) {
|
||||
return { updateMode: vi.fn(() => Promise.resolve(mode)), versions: vi.fn(read) };
|
||||
}
|
||||
|
||||
const MINUTE = 60_000;
|
||||
const DAY = 24 * 60 * MINUTE;
|
||||
|
||||
beforeEach(() => vi.useFakeTimers());
|
||||
afterEach(() => {
|
||||
resetUpdateWatch();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
describe("the background update check", () => {
|
||||
it("reads nothing from the catalog in manual mode", async () => {
|
||||
const p = port("manual");
|
||||
const stop = startUpdateWatch(p);
|
||||
await vi.advanceTimersByTimeAsync(2 * DAY);
|
||||
expect(p.versions).not.toHaveBeenCalled();
|
||||
expect(readUpdateAvailable()).toBe("");
|
||||
stop();
|
||||
});
|
||||
|
||||
it("says a newer release exists once the first delay has passed, in notify mode", async () => {
|
||||
const p = port("notify");
|
||||
const stop = startUpdateWatch(p);
|
||||
await vi.advanceTimersByTimeAsync(30_000);
|
||||
expect(p.versions).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(40_000);
|
||||
expect(p.versions).toHaveBeenCalledTimes(1);
|
||||
expect(readUpdateAvailable()).toBe("2.21.0");
|
||||
stop();
|
||||
});
|
||||
|
||||
it("stays quiet for a pinned build, a withdrawn one, and a catalog with nothing newer", async () => {
|
||||
for (const over of [{ pinned: "2.20.0" }, { withdrawn: { version: "2.20.0", reason: "x" } }, { newer: false, latest: "2.20.0" }]) {
|
||||
const p = port("notify", () => Promise.resolve(hub(over)));
|
||||
const stop = startUpdateWatch(p);
|
||||
await vi.advanceTimersByTimeAsync(2 * MINUTE);
|
||||
expect(p.versions).toHaveBeenCalled();
|
||||
expect(readUpdateAvailable()).toBe("");
|
||||
stop();
|
||||
resetUpdateWatch();
|
||||
}
|
||||
});
|
||||
|
||||
it("fails silently and backs off instead of retrying in a storm", async () => {
|
||||
const p = port("notify", () => Promise.reject(new Error("offline")));
|
||||
const stop = startUpdateWatch(p);
|
||||
await vi.advanceTimersByTimeAsync(70_000);
|
||||
expect(p.versions).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(14 * MINUTE);
|
||||
expect(p.versions).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(2 * MINUTE);
|
||||
expect(p.versions).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(27 * MINUTE);
|
||||
expect(p.versions).toHaveBeenCalledTimes(2);
|
||||
await vi.advanceTimersByTimeAsync(4 * MINUTE);
|
||||
expect(p.versions).toHaveBeenCalledTimes(3);
|
||||
expect(readUpdateAvailable()).toBe("");
|
||||
stop();
|
||||
});
|
||||
|
||||
it("checks again a day after a good answer, and not before", async () => {
|
||||
const p = port("notify");
|
||||
const stop = startUpdateWatch(p);
|
||||
await vi.advanceTimersByTimeAsync(70_000);
|
||||
await vi.advanceTimersByTimeAsync(DAY - 10 * MINUTE);
|
||||
expect(p.versions).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(11 * MINUTE);
|
||||
expect(p.versions).toHaveBeenCalledTimes(2);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("stops asking, and drops what it said, once the user goes back to manual", async () => {
|
||||
let mode: UpdateMode = "notify";
|
||||
const p = { updateMode: vi.fn(() => Promise.resolve(mode)), versions: vi.fn(() => Promise.resolve(hub())) };
|
||||
const stop = startUpdateWatch(p);
|
||||
await vi.advanceTimersByTimeAsync(70_000);
|
||||
expect(readUpdateAvailable()).toBe("2.21.0");
|
||||
mode = "manual";
|
||||
await stop.refresh();
|
||||
expect(readUpdateAvailable()).toBe("");
|
||||
await vi.advanceTimersByTimeAsync(2 * DAY);
|
||||
expect(p.versions).toHaveBeenCalledTimes(1);
|
||||
stop();
|
||||
});
|
||||
|
||||
it("a refresh after choosing notify checks at once", async () => {
|
||||
let mode: UpdateMode = "manual";
|
||||
const p = { updateMode: vi.fn(() => Promise.resolve(mode)), versions: vi.fn(() => Promise.resolve(hub())) };
|
||||
const stop = startUpdateWatch(p);
|
||||
await vi.advanceTimersByTimeAsync(70_000);
|
||||
expect(p.versions).not.toHaveBeenCalled();
|
||||
mode = "notify";
|
||||
await stop.refresh();
|
||||
expect(p.versions).toHaveBeenCalledTimes(1);
|
||||
expect(readUpdateAvailable()).toBe("2.21.0");
|
||||
stop();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,108 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import type { UpdateMode, VersionHub } from "../port/version";
|
||||
|
||||
// In notify mode the window reads the version catalog in the background and
|
||||
// remembers whether a release newer than this build is published. It installs
|
||||
// nothing and shows nothing but a mark. The catalog read is the version panel's
|
||||
// own call; a failure is silent and backs off, so an offline or proxied machine
|
||||
// is not retried in a storm.
|
||||
const FIRST_DELAY = 60_000;
|
||||
const DAY = 24 * 60 * 60_000;
|
||||
const RETRY_FIRST = 15 * 60_000;
|
||||
|
||||
export interface UpdateWatchPort {
|
||||
updateMode(): Promise<UpdateMode>;
|
||||
versions(): Promise<VersionHub>;
|
||||
}
|
||||
|
||||
let available = "";
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
const publish = (next: string) => {
|
||||
if (next === available) return;
|
||||
available = next;
|
||||
for (const listener of listeners) listener();
|
||||
};
|
||||
|
||||
const subscribe = (listener: () => void) => {
|
||||
listeners.add(listener);
|
||||
return () => void listeners.delete(listener);
|
||||
};
|
||||
|
||||
/** The newer release the last background read found, or "". */
|
||||
export const readUpdateAvailable = (): string => available;
|
||||
|
||||
export function useUpdateAvailable(): string {
|
||||
return useSyncExternalStore(subscribe, readUpdateAvailable, readUpdateAvailable);
|
||||
}
|
||||
|
||||
export function resetUpdateWatch(): void {
|
||||
publish("");
|
||||
}
|
||||
|
||||
let active: UpdateWatch | null = null;
|
||||
|
||||
/** Tells the running check the mode changed, so the choice takes effect now. */
|
||||
export function refreshUpdateWatch(): Promise<void> {
|
||||
return active ? active.refresh() : Promise.resolve();
|
||||
}
|
||||
|
||||
export interface UpdateWatch {
|
||||
(): void;
|
||||
/** Reads the mode again and, in notify mode, checks now. */
|
||||
refresh(): Promise<void>;
|
||||
}
|
||||
|
||||
export function startUpdateWatch(port: UpdateWatchPort): UpdateWatch {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
let stopped = false;
|
||||
let failures = 0;
|
||||
|
||||
const later = (ms: number) => {
|
||||
if (stopped) return;
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => void check(), ms);
|
||||
};
|
||||
|
||||
const check = async () => {
|
||||
let mode: UpdateMode;
|
||||
try {
|
||||
mode = await port.updateMode();
|
||||
} catch {
|
||||
failures++;
|
||||
later(Math.min(DAY, RETRY_FIRST * 2 ** (failures - 1)));
|
||||
return;
|
||||
}
|
||||
if (stopped) return;
|
||||
if (mode !== "notify") {
|
||||
publish("");
|
||||
failures = 0;
|
||||
later(DAY);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const hub = await port.versions();
|
||||
if (stopped) return;
|
||||
failures = 0;
|
||||
publish(hub.newer && !hub.pinned && !hub.withdrawn && hub.latest ? hub.latest : "");
|
||||
later(DAY);
|
||||
} catch {
|
||||
failures++;
|
||||
later(Math.min(DAY, RETRY_FIRST * 2 ** (failures - 1)));
|
||||
}
|
||||
};
|
||||
|
||||
later(FIRST_DELAY);
|
||||
const stop = (() => {
|
||||
stopped = true;
|
||||
clearTimeout(timer);
|
||||
if (active === stop) active = null;
|
||||
}) as UpdateWatch;
|
||||
stop.refresh = async () => {
|
||||
failures = 0;
|
||||
clearTimeout(timer);
|
||||
await check();
|
||||
};
|
||||
active = stop;
|
||||
return stop;
|
||||
}
|
||||
@@ -76,8 +76,13 @@
|
||||
}
|
||||
:root[data-bg] .tabs {
|
||||
background: color-mix(in srgb, var(--surface) 62%, transparent);
|
||||
border-bottom-color: transparent;
|
||||
text-shadow: 0 1px 2px color-mix(in srgb, var(--page) 88%, transparent);
|
||||
}
|
||||
:root[data-bg] .tabs::after {
|
||||
content: ""; position: absolute; left: 0; right: 0; top: 100%; height: 18px; z-index: 2; pointer-events: none;
|
||||
background: linear-gradient(color-mix(in srgb, var(--surface) 62%, transparent), transparent);
|
||||
}
|
||||
|
||||
:root[data-bg] .flow:not([data-empty]) {
|
||||
text-shadow: var(--flow-text-shadow, 0 1px 2px color-mix(in srgb, var(--page) 90%, transparent),
|
||||
@@ -1123,7 +1128,8 @@
|
||||
@keyframes bob { 0%, 100% { opacity: .22; transform: translateY(0) } 50% { opacity: 1; transform: translateY(-3px) } }
|
||||
|
||||
.think { margin-top: 5px }
|
||||
.think > summary { display: inline-flex }
|
||||
.think > summary { display: inline-flex; max-width: 100%; align-items: baseline; gap: 8px }
|
||||
.think .peek { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-reg) var(--fs-ui) var(--ui); color: var(--faint) }
|
||||
.think .tk {
|
||||
margin-top: 5px; padding-left: 14px;
|
||||
font: var(--w-reg) var(--fs-ui)/1.68 var(--ui); color: var(--faint); white-space: pre-wrap;
|
||||
@@ -3148,6 +3154,10 @@
|
||||
.ml-head span { font: var(--w-reg) var(--fs-meta) var(--ui); color: var(--faint) }
|
||||
.ml-row { display: grid; grid-template-columns: minmax(0, 1fr) 150px 150px; align-items: center; gap: 6px 10px; padding: 8px 0; border-bottom: 1px solid var(--hair) }
|
||||
.addp .ml-row .unit-field input { padding: 6px 8px; font-size: var(--fs-meta) }
|
||||
.mp-row { grid-template-columns: repeat(4, minmax(0, 1fr)) }
|
||||
.mp-row .me-name { grid-column: 1 / -1 }
|
||||
.mp-row .why { grid-column: 1 / -1 }
|
||||
.mp-row select { padding: 6px 8px; font-size: var(--fs-meta) }
|
||||
.ml-row .me-name { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font: var(--w-med) var(--fs-ui) var(--mono); color: var(--text) }
|
||||
.ml-row input::placeholder { color: var(--ghost) }
|
||||
.ml-row[data-own] .unit-field:has(input:not(:placeholder-shown)) { border-color: var(--accent) }
|
||||
@@ -4419,11 +4429,12 @@
|
||||
.ptab-host { flex: none; font: var(--w-reg) var(--fs-micro) var(--mono); color: var(--net) }
|
||||
.ptab-host::before { content: "◦"; margin-right: 2px; opacity: .7 }
|
||||
.ptab .pip { flex: none; background: var(--ghost) }
|
||||
.ptab-unread { flex: none; width: 6px; height: 6px; margin-inline-start: -3px; border-radius: 50%; background: var(--accent-ink) }
|
||||
|
||||
.ptab[data-run="running"] .pip { background: var(--net) }
|
||||
.ptab[data-run="running"] .pip { background: var(--net); animation: breathe 1.4s ease-in-out infinite }
|
||||
@media (prefers-reduced-motion: reduce) { .ptab[data-run="running"] .pip { animation: none } }
|
||||
.ptab[data-run="halt"] .pip { background: var(--accent-ink) }
|
||||
.ptab[data-run="done"] .pip { background: transparent; box-shadow: inset 0 0 0 1.5px var(--ok) }
|
||||
.ptab:not([data-run="running"]) .pip[data-unread] { background: var(--accent-ink); box-shadow: 0 0 0 2px color-mix(in srgb, var(--accent-ink) 28%, transparent) }
|
||||
.ptab-x {
|
||||
flex: none; width: 17px; height: 17px; display: grid; place-items: center;
|
||||
border: 0; border-radius: var(--r-xs); background: transparent; color: var(--ghost);
|
||||
@@ -5139,6 +5150,8 @@
|
||||
background: var(--overlay); border-left: 2px solid var(--accent);
|
||||
animation: card var(--m-line) both;
|
||||
}
|
||||
.wsconfirm[data-wide] { flex-direction: column; align-items: stretch; gap: 6px }
|
||||
.wsconfirm[data-wide] .wsconfirm-a { flex-wrap: wrap; justify-content: flex-end }
|
||||
.wsconfirm-t { flex: 1; min-width: 0; display: grid; gap: 1px }
|
||||
.wsconfirm-t .q {
|
||||
font: var(--w-med) var(--fs-ui) var(--ui); color: var(--text);
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
.ask-foot .ask-skip {
|
||||
color: var(--faint); font: var(--w-reg) var(--fs-ui) var(--ui); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
@@ -257,6 +257,16 @@
|
||||
display: flex; flex-direction: column; gap: 8px; padding: 12px 14px;
|
||||
border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised);
|
||||
}
|
||||
.fbk-link {
|
||||
display: flex; flex-direction: column; gap: 8px; padding: 12px 14px;
|
||||
border: 1px solid var(--border); border-radius: var(--r-sm); background: var(--raised);
|
||||
}
|
||||
.fbk-link-hd { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
|
||||
.fbk-link-name { font: var(--w-semi) var(--fs-lead)/1.35 var(--ui); color: var(--text); }
|
||||
.fbk-link-row { display: flex; align-items: center; flex-wrap: wrap; gap: 10px; }
|
||||
.fbk-link-list { list-style: none; display: flex; flex-direction: column; gap: 6px; }
|
||||
.fbk-link-list li { display: flex; align-items: center; gap: 10px; min-width: 0; }
|
||||
.fbk-link .fbk-hint[data-tone="error"] { color: var(--err-ink); }
|
||||
.fbk-level[data-stale] { border-style: dashed; }
|
||||
.fbk-level-hd { display: flex; align-items: center; gap: 12px; min-width: 0; }
|
||||
.fbk-level-id { display: flex; flex-direction: column; min-width: 0; }
|
||||
@@ -269,3 +279,4 @@
|
||||
.fbk-level-limits, .fbk-level-note, .fbk-level-stale { margin: 0; font: var(--w-reg) var(--fs-meta)/1.55 var(--ui); color: var(--muted); }
|
||||
.fbk-level-limits { color: var(--text); }
|
||||
.fbk-level-gone { margin: 0; }
|
||||
.fbk-reply-shots { display: grid; gap: 6px; margin-top: 8px; }
|
||||
@@ -1,4 +1,10 @@
|
||||
.studio-model-group { flex-wrap: wrap; }
|
||||
.studio-model-group { flex-wrap: nowrap; }
|
||||
@container conversation (max-width: 720px) {
|
||||
.turntools { flex-wrap: nowrap; }
|
||||
.studio-model-group { flex: 1 1 0; }
|
||||
.studio-model-control { flex: 0 1 auto; min-width: 64px; }
|
||||
.studio-model-control .model-picker .nm { max-width: none; }
|
||||
}
|
||||
.chrome .r-fold { display: contents; }
|
||||
[data-fold~="bar"] .chrome .r-fold { display: none; position: absolute; top: calc(100% + 6px); right: 12px; z-index: 80; flex-wrap: wrap; gap: 5px; padding: 6px; border: 1px solid var(--border); border-radius: 10px; background: var(--raised); box-shadow: 0 12px 32px -14px #000; }
|
||||
[data-fold~="bar"] .chrome .r-fold[data-open] { display: flex; }
|
||||
@@ -8,3 +14,7 @@
|
||||
@container conversation (max-width: 520px) { .studio-deckchip > span { display: none; } }
|
||||
.chrome .thbtn.r-more { color: var(--muted); }
|
||||
.chrome .thbtn.r-more svg { stroke-width: 2.6; }
|
||||
@container conversation (max-width: 480px) {
|
||||
.studio-overview-anchor { position: static; }
|
||||
.studio-overview-anchor .studio-overview-popover { left: 0; right: 0; width: auto; }
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
.studio-overview-anchor { position: relative; height: 25px; display: flex; align-items: center; }
|
||||
.studio-overview-anchor > button { cursor: pointer; }
|
||||
.studio-overview-anchor .studio-overview-popover {
|
||||
top: auto; right: auto; bottom: calc(100% + 8px); left: 0;
|
||||
width: min(380px, calc(100vw - 32px)); transform: translateY(4px) scale(.985); transform-origin: bottom left;
|
||||
}
|
||||
.studio-overview-anchor .studio-overview-popover::before { content: ""; position: absolute; left: 0; right: 0; top: 100%; height: 10px; }
|
||||
.studio-overview-anchor[data-open] .studio-overview-popover { opacity: 1; visibility: visible; pointer-events: auto; transform: none; transition-delay: 0s; }
|
||||
.studio-meterrail .studio-overview-anchor > button {
|
||||
height: 25px; display: inline-flex; align-items: center; gap: 5px; padding: 0 6px; border: 0; border-radius: 5px; background: transparent;
|
||||
color: var(--faint); white-space: nowrap; font: 500 10px/1 var(--ui); font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.studio-meterrail .studio-overview-anchor > button:hover { background: color-mix(in srgb, var(--text) 7%, transparent); color: var(--text); }
|
||||
:root[data-theme="light"] .studio-meterrail .studio-overview-anchor > button { color: var(--ghost); }
|
||||
.studio-overview-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 10px 14px; margin: 0; }
|
||||
.studio-overview-grid > div { display: grid; gap: 2px; min-width: 0; }
|
||||
.studio-overview-grid dt { color: var(--ghost); font: 500 9px/1.3 var(--ui); }
|
||||
.studio-overview-grid dd { margin: 0; color: var(--text); font: 600 13px/1.2 var(--mono); }
|
||||
.studio-overview-grid small { color: var(--ghost); font: 500 9px/1.4 var(--ui); }
|
||||
.studio-overview-row { display: grid; grid-template-columns: minmax(0, 1.3fr) 3.2ch 5ch 4.5ch minmax(0, 1.2fr); align-items: center; gap: 8px; min-height: 22px; padding: 0 5px; border-radius: 5px; }
|
||||
.studio-overview-row:hover { background: var(--overlay); }
|
||||
.studio-overview-row > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
|
||||
.studio-overview-row > b { color: var(--muted); font: 550 9px/1 var(--mono); text-align: right; }
|
||||
.studio-overview-row > small { text-align: right; font-family: var(--mono); }
|
||||
@media (hover: hover) {
|
||||
.studio-overview-anchor:hover .studio-overview-popover { opacity: 1; visibility: visible; pointer-events: auto; transform: none; transition-delay: 0s; }
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
.tabs:has(.plan-peek-pop) { z-index: 25; }
|
||||
.plan-peek { margin-inline-start: auto; flex: none; }
|
||||
.plan-peek + .sidetoggle { margin-left: 8px; }
|
||||
.plan-peek-trigger { display: flex; align-items: center; gap: 6px; }
|
||||
.plan-peek-trigger .studio-icon { width: 13px; height: 13px; }
|
||||
.plan-peek-trigger small { font: 500 10px/1 var(--mono); font-variant-numeric: tabular-nums; }
|
||||
|
||||
@@ -2,12 +2,13 @@
|
||||
.scrolljump > div { position: absolute; right: 14px; bottom: 12px; display: grid; gap: 6px; pointer-events: auto; }
|
||||
.railhost ~ .scrolljump > div { right: 45px; }
|
||||
.scrolljump button {
|
||||
display: grid; place-items: center; width: 30px; height: 30px; padding: 0; cursor: pointer;
|
||||
border: 1px solid var(--border); border-radius: 999px; background: var(--overlay); color: var(--muted);
|
||||
box-shadow: 0 4px 14px -4px rgba(0, 0, 0, .3);
|
||||
display: grid; place-items: center; width: 28px; height: 28px; padding: 0; cursor: pointer;
|
||||
border: 1px solid var(--hair); border-radius: 8px; background: color-mix(in srgb, var(--surface) 93%, transparent); color: var(--muted);
|
||||
backdrop-filter: blur(6px);
|
||||
box-shadow: 0 2px 8px -3px rgba(0, 0, 0, .18);
|
||||
animation: card var(--t-card) var(--ease-out) both;
|
||||
}
|
||||
.scrolljump button:hover { color: var(--text); border-color: var(--faint); }
|
||||
.scrolljump button:hover { color: var(--text); background: var(--surface); border-color: var(--border); }
|
||||
.scrolljump button:focus-visible { outline: 2px solid var(--focus); outline-offset: 2px; }
|
||||
.scrolljump svg { width: 14px; height: 14px; }
|
||||
.scrolljump button[data-value="up"] svg { rotate: 180deg; }
|
||||
|
||||
@@ -149,6 +149,8 @@ body { background: var(--page); }
|
||||
.app:not([data-nav="on"]) .nav { border-right-width: 0; }
|
||||
[data-titlebar="app"] .nav { -webkit-app-region: drag; }
|
||||
[data-titlebar="app"] .nav button { -webkit-app-region: no-drag; }
|
||||
.studio-settings, .navbtn { position: relative; }
|
||||
.studio-settings .upd-dot, .navbtn .upd-dot { position: absolute; top: 5px; right: 5px; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
|
||||
.navbtn .fbk-badge { position: absolute; top: 3px; right: 2px; min-width: 14px; height: 14px; padding: 0 4px; display: inline-grid; place-items: center; border-radius: var(--r-pill); background: var(--accent); color: var(--accent-fg); font: var(--w-med) 9px/1 var(--ui); }
|
||||
.navgrp { gap: 3px; }
|
||||
.navbtn { width: 36px; height: 36px; border-radius: 8px; }
|
||||
@@ -1272,6 +1274,7 @@ body { background: var(--page) !important; }
|
||||
top: auto; right: auto; bottom: calc(100% + 8px); left: 0;
|
||||
width: min(326px, calc(100vw - 32px)); transform: translateY(4px) scale(.985); transform-origin: bottom left;
|
||||
}
|
||||
.studio-context-anchor .studio-context-summary::before { content: ""; position: absolute; left: 0; right: 0; top: 100%; height: 10px; }
|
||||
.studio-context-anchor[data-open] .studio-context-summary {
|
||||
opacity: 1; visibility: visible; pointer-events: auto; transform: none; transition-delay: 0s;
|
||||
}
|
||||
@@ -2407,13 +2410,13 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.studio-meterrail .studio-deck-anchor { position: static; }
|
||||
.turntools .policy { position: static; }
|
||||
.turntools .polmenu { right: 0; width: auto; min-width: 0; max-width: none; }
|
||||
.studio-mode-picker { width: 30px; justify-content: center; padding-inline: 0 !important; }
|
||||
.studio-mode-picker > span:not(.studio-sr-label),
|
||||
.studio-mode-picker > .studio-icon:last-child { display: none; }
|
||||
}
|
||||
|
||||
@container conversation (max-width: 360px) {
|
||||
.studio-branch-pop { display: none; }
|
||||
.studio-mode-picker { width: 30px; justify-content: center; padding-inline: 0 !important; }
|
||||
.studio-mode-picker > span:not(.studio-sr-label),
|
||||
.studio-mode-picker > .studio-icon:last-child { display: none; }
|
||||
.studio-meterrail .studio-meter-cost,
|
||||
.studio-meterrail .studio-meter-mask { display: none; }
|
||||
.studio-meterrail .studio-meter-wallet { margin-left: auto; }
|
||||
@@ -3263,6 +3266,8 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
.studio-wallet { color: var(--muted); }
|
||||
.studio-wallet b { color: var(--muted); }
|
||||
.studio-wallet small { color: var(--text); font-variant-numeric: tabular-nums; }
|
||||
.studio-wallet.studio-feedback > span, .studio-wallet.studio-feedback b { color: var(--accent-ink); }
|
||||
.studio-wallet.studio-feedback b { font-weight: 600; }
|
||||
.studio-wallet:hover { background: var(--accent-wash); color: var(--text); }
|
||||
.studio-user-foot { border-top-color: var(--hair); }
|
||||
.studio-workspace-kind { color: var(--muted); }
|
||||
@@ -4098,6 +4103,7 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
animation: remote-toast-in 180ms ease-out both;
|
||||
}
|
||||
.remote-closed > span { width: 8px; height: 8px; flex: 0 0 auto; border-radius: 50%; background: var(--err); box-shadow: 0 0 0 4px color-mix(in srgb, var(--err) 14%, transparent); }
|
||||
.remote-closed.info > span { background: var(--accent); box-shadow: 0 0 0 4px color-mix(in srgb, var(--accent) 14%, transparent); }
|
||||
.remote-closed > div { min-width: 0; flex: 1; }
|
||||
.remote-closed b { display: block; font: 600 13px/1.4 var(--ui); color: var(--text); }
|
||||
.remote-closed p { margin: 2px 0 0; color: var(--muted); font: 400 11px/1.45 var(--ui); }
|
||||
@@ -4207,3 +4213,22 @@ body { font-size: 14px; line-height: 1.75; }
|
||||
box-shadow: 0 0 0 1px var(--accent-wash);
|
||||
}
|
||||
:root[data-pack] .flow .opt .mark { border-color: var(--accent); }
|
||||
|
||||
.openwith { position: relative; display: inline-flex; }
|
||||
.openwith-btn { display: inline-flex; align-items: center; gap: 2px; min-height: 25px; padding: 0 6px; border: 1px solid var(--hair); border-radius: 5px; background: transparent; color: var(--muted); }
|
||||
.openwith-btn:hover, .openwith-btn[aria-expanded="true"] { color: var(--text); background: var(--overlay); }
|
||||
.openwith-btn .studio-icon { width: 13px; height: 13px; }
|
||||
.openwith-menu { position: absolute; inset-inline-end: 0; top: calc(100% + 4px); z-index: 40; min-width: 190px; padding: 4px; display: grid; gap: 1px; border: 1px solid var(--border); border-radius: 8px; background: var(--raised); box-shadow: var(--shadow-lg); }
|
||||
.openwith-menu button { text-align: start; padding: 7px 10px; border: 0; border-radius: 5px; background: transparent; color: var(--text); font: 500 12px/1.3 var(--ui); }
|
||||
.openwith-menu button:hover, .openwith-menu button:focus-visible { background: var(--overlay); }
|
||||
.openwith-menu small { padding: 7px 10px; color: var(--muted); }
|
||||
.shell .pinpath .fields { display: grid; gap: 10px; margin-top: 6px; }
|
||||
.shell .pinpath .fields label { display: grid; gap: 4px; }
|
||||
.shell .pinpath .fields input, .shell .pinpath .fields textarea { width: 100%; box-sizing: border-box; padding: 6px 8px; border: 1px solid var(--hair); border-radius: 6px; background: var(--page); color: var(--text); font-family: var(--mono); }
|
||||
.shell .pinpath .fields small { color: var(--muted); line-height: 1.5; }
|
||||
|
||||
.skupd-diff { margin: 6px 0 0; padding: 8px 10px; max-height: 240px; overflow: auto; border: 1px solid var(--hair); border-radius: 6px; background: var(--surface); font: 12px/1.5 var(--mono); white-space: pre; }
|
||||
.skupd-diff span[data-d="+"] { color: color-mix(in srgb, var(--add) 62%, var(--text)); background: color-mix(in srgb, var(--add) 9%, transparent); }
|
||||
.skupd-diff span[data-d="-"] { color: color-mix(in srgb, var(--del) 58%, var(--text)); background: color-mix(in srgb, var(--del) 8%, transparent); }
|
||||
.skupd { margin: 6px 0 14px; }
|
||||
.workbench-tab-in { min-width: 0; width: 100%; padding: 1px 4px; border: 1px solid var(--accent); border-radius: 4px; background: var(--page); color: var(--text); font: 500 11px/1.4 var(--ui); outline: none; }
|
||||
@@ -5,6 +5,7 @@ import { Community } from "./Community";
|
||||
import { Group } from "./Group";
|
||||
import { Logs } from "./Logs";
|
||||
import { Sponsor } from "./Sponsor";
|
||||
import { UpdateMode } from "./UpdateMode";
|
||||
import { Versions } from "./Versions";
|
||||
|
||||
export function About({ port }: { port: AgentPort }) {
|
||||
@@ -16,6 +17,12 @@ export function About({ port }: { port: AgentPort }) {
|
||||
>
|
||||
<Versions port={port} />
|
||||
</Group>
|
||||
<Group id="update-mode"
|
||||
title={t("更新方式")}
|
||||
hint={t("决定窗口自己做不做事:两种方式都不会替你安装新版本,重启始终由你决定。")}
|
||||
>
|
||||
<UpdateMode port={port} />
|
||||
</Group>
|
||||
<Group id="logs"
|
||||
title={t("日志与异常退出记录")}
|
||||
hint={t("出现问题或闪退后,反馈时附上这个目录里的文件会更容易查清原因;这里只显示位置,不会上传任何内容。")}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useBranchSession } from "./useBranchSession";
|
||||
import { watchTreeWake } from "./treewake";
|
||||
import { usePinnedSessions } from "./usePinnedSessions";
|
||||
import { AutoArchiveNotice } from "./AutoArchiveNotice";
|
||||
@@ -22,6 +23,7 @@ import { clearDraftForSession } from "./drafts";
|
||||
import { DOCK, Gutter, RAIL, dockMax, keepWidth, widthOf } from "./Gutter";
|
||||
import { useWindowKeys, type Shortcut } from "./windowkeys";
|
||||
import { useEscStop } from "../state/escstop";
|
||||
import { startUpdateWatch } from "../state/updatewatch";
|
||||
import { StopHint } from "./StopHint";
|
||||
import { folded as roomGaveUp, onRoomWidth } from "./viewport";
|
||||
import { useFoldAway } from "./foldaway";
|
||||
@@ -294,6 +296,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
|
||||
// Asked on open, on focus (at most every 30 s) and every 10 min while a report
|
||||
// is unsettled, never otherwise; a failed refresh stays silent and backs off.
|
||||
useEffect(() => startUpdateWatch(machinePort), [machinePort]);
|
||||
useFeedbackUnread(machineRt ? machinePort : null, machineRt?.id ?? "", feedbackUnread, setFeedbackUnread, feedback !== null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -528,6 +531,8 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
[closePanes, hub, reloadTree, unpinSession],
|
||||
);
|
||||
|
||||
const branchSession = useBranchSession({ panePorts, runtimes, runsRef, openPane });
|
||||
|
||||
const first = useFirstRun({ port: activePort, setup, welcomed, setSetup, setWelcomed, reloadPanes });
|
||||
|
||||
if (setup === undefined || welcomed === undefined) return <div className="app" data-run="idle" />;
|
||||
@@ -622,6 +627,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
onFocusPane={focusPane}
|
||||
onClosePanes={closePanes}
|
||||
onPause={(runtimeId) => void panePorts.get(runtimeId)?.cancel()}
|
||||
onBranch={branchSession}
|
||||
onArchive={archiveSession}
|
||||
onRename={renameSession}
|
||||
onCollapse={() => setRail(false)}
|
||||
@@ -668,6 +674,7 @@ export function App({ hub }: { hub: HubPort }) {
|
||||
key={`${rt.id}:${takeover[rt.id] ?? 0}`}
|
||||
rt={rt}
|
||||
port={port}
|
||||
hub={hub}
|
||||
title={tabs.find((tab) => tab.rt.id === rt.id)?.title ?? t("新会话")}
|
||||
active={rt.id === active}
|
||||
sideHost={null}
|
||||
|
||||
@@ -17,7 +17,7 @@ interface Props {
|
||||
// Called when a switch lands (status refresh) and when it moved the tree
|
||||
// (the change list, the branch reading and the workspace view re-read).
|
||||
onChanged: () => void;
|
||||
onSwitched?: () => void;
|
||||
onSwitched?: (git: WorkspaceGit) => void;
|
||||
onError: (e: unknown) => void;
|
||||
}
|
||||
|
||||
@@ -74,11 +74,26 @@ export function BranchChip({ port, git, changeCount, countElsewhere = false, onC
|
||||
if (busy) return;
|
||||
setBusy(true);
|
||||
void port.switchBranch(name)
|
||||
.then(() => { onChanged(); onSwitched?.(); })
|
||||
.then((git) => { onChanged(); onSwitched?.(git); })
|
||||
.catch(onError)
|
||||
.finally(() => setBusy(false));
|
||||
};
|
||||
|
||||
if (git && !git.repo && git.absent) {
|
||||
const refused = git.absent === "refused";
|
||||
return (
|
||||
<div className="studio-branch-pop" onMouseEnter={(e) => fitCard(e.currentTarget)} onFocusCapture={(e) => fitCard(e.currentTarget)}>
|
||||
<div className="mode plain studio-branch" data-norepo="" data-absent={git.absent} role="img" aria-label={refused ? t("Git 拒绝打开") : t("Git 未响应")} tabIndex={0} aria-describedby={branchTipId}>
|
||||
<span className="ic" aria-hidden="true"><StudioIcon name="branch" /></span>
|
||||
<span className="lb">{refused ? t("Git 拒绝打开") : t("Git 未响应")}</span>
|
||||
</div>
|
||||
<div className="studio-branch-card" style={cardStyle} id={branchTipId} role="tooltip">
|
||||
<b>{refused ? t("这个文件夹有 Git 仓库,但 Git 拒绝打开它") : t("这个文件夹有 Git 仓库,但 Git 没有及时响应")}</b>
|
||||
<span>{refused ? (git.detail ? t("Git 说:{detail}", { detail: git.detail }) : t("请在终端里运行 git status 查看原因")) : t("分支暂时无法显示。重新打开这个文件夹,或重启 Studio 后再试")}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (git && !git.repo) {
|
||||
return (
|
||||
<div className="studio-branch-pop" onMouseEnter={(e) => fitCard(e.currentTarget)} onFocusCapture={(e) => fitCard(e.currentTarget)}>
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { cleanup, render } from "@testing-library/react";
|
||||
import "./testkit";
|
||||
import { useState } from "react";
|
||||
import { useCompletion } from "./Completion";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import type { Completion } from "../port/model";
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const answer = (text: string): Completion => ({ kind: "slash", from: 0, to: text.length, query: text, items: [{ label: text, insert: text, kind: "builtin" }] });
|
||||
|
||||
function drawKeystrokes(complete: (line: string, cursor: number, signal?: AbortSignal) => Promise<Completion>) {
|
||||
const port = { complete } as unknown as AgentPort;
|
||||
let type: (text: string) => void = () => {};
|
||||
function Probe() {
|
||||
const [text, setText] = useState("/");
|
||||
type = setText;
|
||||
useCompletion(port, text, text.length, () => {});
|
||||
return null;
|
||||
}
|
||||
const view = render(<Probe />);
|
||||
return { type: (text: string) => view.rerender((type(text), <Probe />)), view };
|
||||
}
|
||||
|
||||
it("abandons the question a keystroke has made stale", async () => {
|
||||
const seen: { line: string; signal: AbortSignal }[] = [];
|
||||
const complete = vi.fn(async (line: string, _cursor: number, signal?: AbortSignal) => {
|
||||
seen.push({ line, signal: signal! });
|
||||
return answer(line);
|
||||
});
|
||||
const { type } = drawKeystrokes(complete);
|
||||
await vi.waitFor(() => expect(seen.length).toBe(1));
|
||||
type("/m");
|
||||
await vi.waitFor(() => expect(seen.length).toBe(2));
|
||||
expect(seen[0].signal.aborted).toBe(true);
|
||||
expect(seen[1].signal.aborted).toBe(false);
|
||||
});
|
||||
|
||||
it("abandons the question when the box goes away", async () => {
|
||||
const seen: AbortSignal[] = [];
|
||||
const { view } = drawKeystrokes(async (line, _cursor, signal) => {
|
||||
seen.push(signal!);
|
||||
return answer(line);
|
||||
});
|
||||
await vi.waitFor(() => expect(seen.length).toBe(1));
|
||||
view.unmount();
|
||||
expect(seen[0].aborted).toBe(true);
|
||||
});
|
||||
@@ -50,9 +50,9 @@ interface ChipHost {
|
||||
touches: (caret: number) => boolean;
|
||||
}
|
||||
|
||||
function ask(port: AgentPort, text: string, caret: number, mid: ReturnType<typeof midSlash>): Promise<Completion> {
|
||||
if (!mid) return port.complete(text, caret);
|
||||
return port.complete(mid.token, mid.token.length).then((r) => ({
|
||||
function ask(port: AgentPort, text: string, caret: number, mid: ReturnType<typeof midSlash>, signal: AbortSignal): Promise<Completion> {
|
||||
if (!mid) return port.complete(text, caret, signal);
|
||||
return port.complete(mid.token, mid.token.length, signal).then((r) => ({
|
||||
kind: "slash",
|
||||
from: mid.from,
|
||||
to: mid.to,
|
||||
@@ -123,7 +123,8 @@ export function useCompletion(
|
||||
const timer = slashy && !compatible
|
||||
? window.setTimeout(() => { if (id === asked.current) setLoading(true); }, 180)
|
||||
: undefined;
|
||||
ask(port, text, caret, mid)
|
||||
const asking = new AbortController();
|
||||
ask(port, text, caret, mid, asking.signal)
|
||||
.then((r) => {
|
||||
if (id === asked.current) {
|
||||
window.clearTimeout(timer);
|
||||
@@ -138,7 +139,10 @@ export function useCompletion(
|
||||
setLoading(false);
|
||||
}
|
||||
});
|
||||
return () => window.clearTimeout(timer);
|
||||
return () => {
|
||||
window.clearTimeout(timer);
|
||||
asking.abort();
|
||||
};
|
||||
}, [port, text, caret]);
|
||||
|
||||
// A keystroke changed the list under the pointer, so its claim on the
|
||||
|
||||
@@ -560,6 +560,19 @@ describe("composer menus", () => {
|
||||
expect(screen.getByRole("img", { name: "非 Git 仓库" })).toBeTruthy();
|
||||
});
|
||||
|
||||
it("says git refused a folder that has a repository, with what git printed, and not 'no repository'", () => {
|
||||
draw({ git: git({ repo: false, absent: "refused", detail: "fatal: detected dubious ownership in repository" }) });
|
||||
expect(screen.queryByRole("img", { name: "非 Git 仓库" })).toBeNull();
|
||||
expect(screen.getByRole("img", { name: "Git 拒绝打开" })).toBeTruthy();
|
||||
expect(document.body.textContent).toContain("Git 说:fatal: detected dubious ownership in repository");
|
||||
});
|
||||
|
||||
it("says git did not answer, which is a different thing from refusing", () => {
|
||||
draw({ git: git({ repo: false, absent: "unavailable" }) });
|
||||
expect(screen.getByRole("img", { name: "Git 未响应" })).toBeTruthy();
|
||||
expect(document.body.textContent).toContain("Git 没有及时响应");
|
||||
});
|
||||
|
||||
// Not answered yet is not the same fact as not a repository, and the two
|
||||
// must not render as one word.
|
||||
it("renders nothing before git has answered", () => {
|
||||
|
||||
@@ -59,9 +59,8 @@ interface Props {
|
||||
// path as changeCount (turn boundaries, writes). null is "not answered yet";
|
||||
// repo:false is a workspace with no repository, and the two render apart.
|
||||
git?: WorkspaceGit | null;
|
||||
// Called after a branch switch landed, so the change list, the branch reading
|
||||
// and the session's workspace view re-read what the checkout moved.
|
||||
onTreeChanged?: () => void;
|
||||
// The switch already answers with the workspace's Git state.
|
||||
onTreeChanged?: (git: WorkspaceGit) => void;
|
||||
// Bumped when settings change; a source edited there can change the ladder.
|
||||
pulse?: number;
|
||||
draftKey?: string;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { EditConn } from "./EditConn";
|
||||
import { MockPort } from "../port/mock";
|
||||
import type { ProviderEdit, ProviderEntry } from "../port/port";
|
||||
|
||||
afterEach(() => { cleanup(); vi.restoreAllMocks(); });
|
||||
|
||||
const entry = (over: Partial<ProviderEntry> = {}): ProviderEntry => ({
|
||||
name: "gw", kind: "openai", baseUrl: "https://gw.example/v1", models: ["alpha", "beta"], default: "alpha", hasKey: true,
|
||||
keyEnv: "GW_KEY", inUse: false, preset: false, ...over,
|
||||
} as ProviderEntry);
|
||||
|
||||
async function open(e: ProviderEntry) {
|
||||
const port = new MockPort();
|
||||
const edit = vi.spyOn(port, "editProvider").mockResolvedValue(undefined as never);
|
||||
render(<EditConn entry={e} port={port as never} busy="" setBusy={() => {}} onDone={() => {}} onRevert={() => {}} />);
|
||||
await screen.findByText("按模型设置价格");
|
||||
return { edit };
|
||||
}
|
||||
const field = (name: string) => screen.getByRole<HTMLInputElement>("textbox", { name });
|
||||
const save = () => screen.getByRole<HTMLButtonElement>("button", { name: /保存/ });
|
||||
|
||||
describe("pricing a model on a connection", () => {
|
||||
it("saves a typed price for one model and leaves the other unpriced", async () => {
|
||||
const { edit } = await open(entry());
|
||||
fireEvent.change(field("alpha 的输入单价"), { target: { value: "3" } });
|
||||
fireEvent.change(field("alpha 的输出单价"), { target: { value: "15" } });
|
||||
fireEvent.change(field("alpha 的缓存命中单价"), { target: { value: "0.3" } });
|
||||
await userEvent.selectOptions(screen.getByRole("combobox", { name: "alpha 的货币" }), "USD");
|
||||
await userEvent.click(save());
|
||||
await waitFor(() => expect(edit).toHaveBeenCalled());
|
||||
const sent = edit.mock.calls[0]![0] as ProviderEdit;
|
||||
expect(sent.modelPrices).toEqual({ alpha: { input: 3, output: 15, cacheHit: 0.3, currency: "USD" } });
|
||||
});
|
||||
|
||||
it("blocks the save while a row has only one of input and output, and says why", async () => {
|
||||
await open(entry());
|
||||
fireEvent.change(field("beta 的输入单价"), { target: { value: "2" } });
|
||||
expect(screen.getByText("输入价和输出价需要都填写,才会保存这一行。")).toBeTruthy();
|
||||
expect(save().disabled).toBe(true);
|
||||
fireEvent.change(field("beta 的输出单价"), { target: { value: "8" } });
|
||||
expect(save().disabled).toBe(false);
|
||||
});
|
||||
|
||||
it("shows a stored price and clears it when the row is emptied", async () => {
|
||||
const { edit } = await open(entry({ modelPrices: { alpha: { input: 3, output: 15, cacheHit: 0.3, currency: "USD" } } }));
|
||||
expect(field("alpha 的输入单价").value).toBe("3");
|
||||
expect(field("beta 的输入单价").value).toBe("");
|
||||
fireEvent.change(field("alpha 的输入单价"), { target: { value: "" } });
|
||||
fireEvent.change(field("alpha 的输出单价"), { target: { value: "" } });
|
||||
await userEvent.click(save());
|
||||
await waitFor(() => expect(edit).toHaveBeenCalled());
|
||||
expect((edit.mock.calls[0]![0] as ProviderEdit).modelPrices).toEqual({ alpha: null });
|
||||
});
|
||||
});
|
||||
@@ -132,7 +132,7 @@ it("preserves configured models that a refreshed catalog no longer returns", asy
|
||||
await userEvent.click(screen.getByRole("button", { name: "从服务商读取可用模型" }));
|
||||
|
||||
expect(await screen.findByText("deepseek-new")).toBeTruthy();
|
||||
const preserved = screen.getByText("deepseek-hidden").closest(".mline") as HTMLElement;
|
||||
const preserved = within(document.querySelector(".mlist") as HTMLElement).getByText("deepseek-hidden").closest(".mline") as HTMLElement;
|
||||
expect(within(preserved).getByText("本次未返回")).toBeTruthy();
|
||||
expect(document.querySelector(".mdiff")?.textContent).toContain("有 1 个已配置模型本次未返回,已为你保留。");
|
||||
});
|
||||
@@ -315,7 +315,7 @@ it("offers no model listing read for a protocol that declares none, header or in
|
||||
|
||||
expect(screen.queryByRole("button", { name: "从服务商读取可用模型" })).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "没找到?从服务商读取可用模型" })).toBeNull();
|
||||
expect(screen.getByText("system-one")).toBeTruthy();
|
||||
expect(within(document.querySelector(".mlist") as HTMLElement).getByText("system-one")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("still checks, adds and saves a model on a source with no listing", async () => {
|
||||
|
||||
@@ -11,6 +11,7 @@ import { EffortShape } from "./EffortShape";
|
||||
import type { ModelEffort, ModelLimit, ProviderEdit } from "../port/port";
|
||||
import { KIND_LABEL } from "./vendors";
|
||||
import { ModelLimits, limitTextOf, limitsToSend, type LimitText } from "./ModelLimits";
|
||||
import { ModelPrices, incomplete, priceTextOf, pricesToSend, type PriceText } from "./ModelPrices";
|
||||
import { useModelBatch } from "./useModelBatch";
|
||||
|
||||
// Only what this form owns is sent: the entry keeps its prices, effort
|
||||
@@ -60,6 +61,9 @@ export function EditConn({
|
||||
const [limitText, setLimitText] = useState<Record<string, LimitText>>(
|
||||
() => Object.fromEntries(Object.entries(entry.modelLimits ?? {}).map(([m, l]) => [m, limitTextOf(l)])),
|
||||
);
|
||||
const [priceText, setPriceText] = useState<Record<string, PriceText>>(
|
||||
() => Object.fromEntries(Object.entries(entry.modelPrices ?? {}).map(([m, r]) => [m, priceTextOf(r)])),
|
||||
);
|
||||
const [think, setThink] = useState(entry.reasoningProtocol ?? "");
|
||||
// Until saved, the thinking field follows the wire being picked: the stored
|
||||
// entry's field belongs to the stored wire.
|
||||
@@ -223,6 +227,7 @@ export function EditConn({
|
||||
defaultEffort: levels.includes(defEffort) ? defEffort : "",
|
||||
modelEfforts: Object.fromEntries(picked.filter((m) => ownEfforts[m]).map((m) => [m, ownEfforts[m]])),
|
||||
modelLimits: limitsToSend(picked, limitText, entry.modelLimits ?? {}),
|
||||
modelPrices: pricesToSend(picked, priceText, entry.modelPrices ?? {}),
|
||||
headers: parseHeaders(heads),
|
||||
extraBody: parseExtraBody(extra) ?? {},
|
||||
});
|
||||
@@ -249,7 +254,7 @@ export function EditConn({
|
||||
const [edited, setEdited] = useState(false);
|
||||
if (dirty && !edited) setEdited(true);
|
||||
|
||||
const canSave = (dirty || err?.kind === "unapplied") && busy === "" && !testing && picked.length > 0 && !extraBad && idle.ok;
|
||||
const canSave = (dirty || err?.kind === "unapplied") && busy === "" && !testing && picked.length > 0 && !extraBad && idle.ok && !picked.some((m) => incomplete(priceText[m]));
|
||||
const phase: Phase = saving ? "saving"
|
||||
: err?.kind === "save" && dirty ? "failed"
|
||||
: dirty ? "dirty"
|
||||
@@ -336,6 +341,7 @@ export function EditConn({
|
||||
{(picked.length > 1 || picked.some((m) => entry.modelLimits?.[m])) && (
|
||||
<ModelLimits models={picked} value={limitText} onChange={setLimitText} inherited={inheritedLimitsFor} />
|
||||
)}
|
||||
{picked.length > 0 && <ModelPrices models={picked} value={priceText} onChange={setPriceText} />}
|
||||
</div>
|
||||
|
||||
<div className="mlist">
|
||||
|
||||
@@ -661,6 +661,25 @@ describe("capturing the window", () => {
|
||||
expect(heldDraft().shots).toEqual([]);
|
||||
});
|
||||
|
||||
it("finishes quietly when the shell answers after the page is torn down", async () => {
|
||||
vi.spyOn(host(), "capturesWindow").mockReturnValue(true);
|
||||
let finish: (b: Uint8Array) => void = () => {};
|
||||
const taking = vi.spyOn(host(), "captureWindow").mockImplementation(() => new Promise((r) => { finish = r; }));
|
||||
const unhandled = vi.fn();
|
||||
const proc = (globalThis as unknown as { process: { on(e: string, f: () => void): void; off(e: string, f: () => void): void } }).process;
|
||||
proc.on("unhandledRejection", unhandled);
|
||||
setup();
|
||||
await ready();
|
||||
await userEvent.click(button());
|
||||
await waitFor(() => expect(taking).toHaveBeenCalled());
|
||||
cleanup();
|
||||
vi.spyOn(globalThis, "requestAnimationFrame").mockImplementation(() => { throw new ReferenceError("window is not defined"); });
|
||||
await act(async () => { finish(new Uint8Array([137, 80, 78, 71])); });
|
||||
await new Promise((r) => setTimeout(r, 30));
|
||||
proc.off("unhandledRejection", unhandled);
|
||||
expect(unhandled).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("offers no button where the shell cannot capture", async () => {
|
||||
vi.spyOn(host(), "capturesWindow").mockReturnValue(false);
|
||||
setup();
|
||||
|
||||
@@ -9,7 +9,7 @@ import { CopyButton } from "./CopyButton";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { dropDraft, heldDraft, holdDraft } from "./feedbackdraft";
|
||||
import { feedbackFailure, type FeedbackFailure } from "./feedbackfailure";
|
||||
import { admit, megabytes, payload, readShot, type Refused, type Shot } from "./feedbackshots";
|
||||
import { admit, megabytes, payload, readShot, REFUSED_LABEL, type Refused, type Shot } from "./feedbackshots";
|
||||
import { useFileDrop } from "./filedrop";
|
||||
|
||||
export const SECURITY_URL = "https://github.com/esengine/DeepSeek-Reasonix/security/policy";
|
||||
@@ -22,12 +22,6 @@ const CATEGORY_LABEL: Record<FeedbackCategory, string> = {
|
||||
other: "其他",
|
||||
};
|
||||
|
||||
const REFUSED_LABEL = {
|
||||
format: "{name}:只支持 PNG 或 JPEG",
|
||||
too_large: "{name}:超过 {size} MB,没有添加",
|
||||
too_many: "{name}:最多 {n} 张,没有添加",
|
||||
} as const;
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
function uuid(): string {
|
||||
@@ -64,6 +58,12 @@ export function FeedbackForm({ port, onMine, onClose, onFile, onConceal }: Props
|
||||
const submit = useRef<HTMLButtonElement>(null);
|
||||
const done = useRef<HTMLHeadingElement>(null);
|
||||
const shotEpoch = useRef(0);
|
||||
const mounted = useRef(true);
|
||||
const focusFrame = useRef(0);
|
||||
useEffect(() => () => {
|
||||
mounted.current = false;
|
||||
cancelAnimationFrame(focusFrame.current);
|
||||
}, []);
|
||||
const captureButton = useRef<HTMLButtonElement>(null);
|
||||
const [capture, setCapture] = useState<"idle" | "taking" | "failed">("idle");
|
||||
const heldShots = useRef(shots);
|
||||
@@ -130,9 +130,10 @@ export function FeedbackForm({ port, onMine, onClose, onFile, onConceal }: Props
|
||||
await new Promise<void>((r) => requestAnimationFrame(() => requestAnimationFrame(() => setTimeout(r, 60))));
|
||||
const bytes = await host().captureWindow();
|
||||
onConceal(false);
|
||||
if (!mounted.current) return;
|
||||
setCapture(bytes ? "idle" : "failed");
|
||||
if (bytes) await add([new File([bytes as BlobPart], "window.png", { type: "image/png" })]);
|
||||
requestAnimationFrame(() => captureButton.current?.focus());
|
||||
if (mounted.current) focusFrame.current = requestAnimationFrame(() => captureButton.current?.focus());
|
||||
};
|
||||
|
||||
const zone = useFileDrop((d) => void add(d.files), setOver);
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { FeedbackLink } from "./FeedbackLink";
|
||||
import { MockPort } from "../port/mock";
|
||||
import { HttpError, type AgentPort } from "../port/port";
|
||||
import { FEEDBACK_CODE } from "../port/feedback";
|
||||
import { boot, STORAGE } from "../i18n";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
localStorage.removeItem("reasonix.mock.account");
|
||||
localStorage.setItem(STORAGE, "zh");
|
||||
boot();
|
||||
});
|
||||
|
||||
const signIn = () => localStorage.setItem("reasonix.mock.account", "1");
|
||||
|
||||
function spied() {
|
||||
const port = new MockPort() as unknown as AgentPort;
|
||||
port.feedbackLinks = vi.fn(port.feedbackLinks.bind(port));
|
||||
port.linkFeedbackAccount = vi.fn(port.linkFeedbackAccount.bind(port));
|
||||
port.unlinkFeedbackAccount = vi.fn(port.unlinkFeedbackAccount.bind(port));
|
||||
port.unlinkFeedbackInstall = vi.fn(port.unlinkFeedbackInstall.bind(port));
|
||||
return port;
|
||||
}
|
||||
|
||||
describe("FeedbackLink", () => {
|
||||
it("is inert while signed out and never asks the service about links", async () => {
|
||||
const port = spied();
|
||||
render(<FeedbackLink port={port} hasReport />);
|
||||
const link = await screen.findByRole("button", { name: "把这台电脑关联到我的账号" });
|
||||
expect((link as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.getByText("先在设置里登录账号,才能关联。")).toBeTruthy();
|
||||
expect(port.feedbackLinks).not.toHaveBeenCalled();
|
||||
await userEvent.click(link);
|
||||
expect(port.linkFeedbackAccount).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("waits for a first report before offering the link", async () => {
|
||||
signIn();
|
||||
const port = spied();
|
||||
render(<FeedbackLink port={port} hasReport={false} />);
|
||||
expect((await screen.findByRole("button", { name: "把这台电脑关联到我的账号" }) as HTMLButtonElement).disabled).toBe(true);
|
||||
expect(screen.getByText("发出第一条反馈之后才能关联这台电脑。")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("links only on the click, then shows this computer as linked and unlinks it again", async () => {
|
||||
signIn();
|
||||
const port = spied();
|
||||
render(<FeedbackLink port={port} hasReport />);
|
||||
const link = await screen.findByRole("button", { name: "把这台电脑关联到我的账号" });
|
||||
expect(port.linkFeedbackAccount).not.toHaveBeenCalled();
|
||||
await userEvent.click(link);
|
||||
await screen.findByText(/这台电脑已关联/);
|
||||
expect(port.linkFeedbackAccount).toHaveBeenCalledTimes(1);
|
||||
await userEvent.click(screen.getByRole("button", { name: "解除关联" }));
|
||||
await screen.findByRole("button", { name: "把这台电脑关联到我的账号" });
|
||||
expect(port.unlinkFeedbackAccount).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lists the account's other computers and unlinks one by its short id", async () => {
|
||||
signIn();
|
||||
const port = spied();
|
||||
render(<FeedbackLink port={port} hasReport />);
|
||||
await screen.findByText("ba9876543210");
|
||||
await userEvent.click(screen.getByRole("button", { name: "解除" }));
|
||||
await waitFor(() => expect(port.unlinkFeedbackInstall).toHaveBeenCalledWith("ba9876543210"));
|
||||
await waitFor(() => expect(screen.queryByText("ba9876543210")).toBeNull());
|
||||
});
|
||||
|
||||
it("says why a refusal happened in its own words", async () => {
|
||||
signIn();
|
||||
const port = spied();
|
||||
port.linkFeedbackAccount = vi.fn(async () => {
|
||||
throw new HttpError(409, "link_conflict", { code: FEEDBACK_CODE.linkConflict, error: "x" });
|
||||
});
|
||||
render(<FeedbackLink port={port} hasReport />);
|
||||
await userEvent.click(await screen.findByRole("button", { name: "把这台电脑关联到我的账号" }));
|
||||
expect((await screen.findByRole("alert")).textContent).toContain("已经关联到另一个账号");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,101 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { current, t } from "../i18n";
|
||||
import type { FeedbackLinkedInstall } from "../port/feedback";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { linkFailure } from "./feedbackfailure";
|
||||
|
||||
interface Props {
|
||||
port: AgentPort;
|
||||
// Linking names this install, which only exists once a report has been sent.
|
||||
hasReport: boolean;
|
||||
}
|
||||
|
||||
const day = (iso: string): string => {
|
||||
const at = new Date(iso);
|
||||
return Number.isNaN(at.getTime()) ? "" : at.toLocaleDateString(current() === "zh" ? "zh-CN" : "en", { year: "numeric", month: "short", day: "numeric" });
|
||||
};
|
||||
|
||||
// The account link is the person's own call: nothing here runs at sign-in, and
|
||||
// every control is inert until an account is signed in.
|
||||
export function FeedbackLink({ port, hasReport }: Props) {
|
||||
const [signedIn, setSignedIn] = useState<boolean | null>(null);
|
||||
const [links, setLinks] = useState<FeedbackLinkedInstall[] | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [problem, setProblem] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const state = await port.account();
|
||||
setSignedIn(state.signedIn && !state.expired);
|
||||
if (!state.signedIn || state.expired) {
|
||||
setLinks(null);
|
||||
return;
|
||||
}
|
||||
setLinks(await port.feedbackLinks());
|
||||
setProblem(null);
|
||||
} catch (e) {
|
||||
setProblem(linkFailure(e).message);
|
||||
}
|
||||
}, [port]);
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, [load]);
|
||||
|
||||
const run = async (act: () => Promise<void>) => {
|
||||
setBusy(true);
|
||||
setProblem(null);
|
||||
try {
|
||||
await act();
|
||||
setLinks(await port.feedbackLinks());
|
||||
} catch (e) {
|
||||
setProblem(linkFailure(e).message);
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (signedIn === null) return null;
|
||||
const here = links?.find((l) => l.current) ?? null;
|
||||
const others = (links ?? []).filter((l) => !l.current);
|
||||
const canLink = signedIn && hasReport && !busy;
|
||||
|
||||
return (
|
||||
<section className="fbk-link" aria-label={t("账号关联")}>
|
||||
<div className="fbk-link-hd">
|
||||
<span className="fbk-link-name">{t("账号关联")}</span>
|
||||
<span className="fbk-hint">{t("关联后,这台电脑的反馈等级会显示在网页账号页上。关联不会改变额度,随时可以解除。")}</span>
|
||||
</div>
|
||||
{!signedIn && <p className="fbk-hint">{t("先在设置里登录账号,才能关联。")}</p>}
|
||||
{signedIn && !hasReport && !here && <p className="fbk-hint">{t("发出第一条反馈之后才能关联这台电脑。")}</p>}
|
||||
<div className="fbk-link-row">
|
||||
{here ? (
|
||||
<>
|
||||
<span className="fbk-chip" data-status="recorded">{t("这台电脑已关联")} · {day(here.linkedAt)}</span>
|
||||
<button type="button" className="btn sm" data-action="feedback.account-unlink" disabled={busy} onClick={() => void run(() => port.unlinkFeedbackAccount())}>
|
||||
{t("解除关联")}
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<button type="button" className="btn sm" data-action="feedback.account-link" disabled={!canLink} onClick={() => void run(() => port.linkFeedbackAccount())}>
|
||||
{t("把这台电脑关联到我的账号")}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{others.length > 0 && (
|
||||
<ul className="fbk-link-list" aria-label={t("账号下的其他电脑")}>
|
||||
{others.map((l) => (
|
||||
<li key={l.id}>
|
||||
<code className="fbk-code">{l.id}</code>
|
||||
<span className="fbk-meta">{day(l.linkedAt)}</span>
|
||||
<button type="button" className="btn sm" data-action="feedback.account-unlink-other" data-target={l.id} disabled={busy} onClick={() => void run(() => port.unlinkFeedbackInstall(l.id))}>
|
||||
{t("解除")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{problem && <p className="fbk-hint" role="alert" data-tone="error">{problem}</p>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -1,10 +1,11 @@
|
||||
import { useCallback, useEffect, useRef, useState, type ReactNode } from "react";
|
||||
import { current, t } from "../i18n";
|
||||
import { tx } from "../i18n/rich";
|
||||
import { FEEDBACK_NEXT_VERSION, FEEDBACK_REPO_ISSUES, isUnderReview, type FeedbackItem, type FeedbackMine as Mine, type FeedbackStatus } from "../port/feedback";
|
||||
import { FEEDBACK_NEXT_VERSION, FEEDBACK_REPO_ISSUES, isUnderReview, type FeedbackItem, type FeedbackLimits, type FeedbackMine as Mine, type FeedbackStatus } from "../port/feedback";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { CopyButton } from "./CopyButton";
|
||||
import { FeedbackLevel } from "./FeedbackLevel";
|
||||
import { FeedbackLink } from "./FeedbackLink";
|
||||
import { feedbackFailure, type FeedbackFailure } from "./feedbackfailure";
|
||||
import { FeedbackReplyBox, FeedbackReviewNote, FeedbackThread, foldedUnread } from "./FeedbackThread";
|
||||
import { StudioIcon, type StudioIconName } from "./StudioIcon";
|
||||
@@ -114,7 +115,7 @@ export function FeedbackMine({ port, onFile, onUnread }: Props) {
|
||||
const [failure, setFailure] = useState<FeedbackFailure | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [fresh, setFresh] = useState<Record<string, number>>({});
|
||||
const [replyBytes, setReplyBytes] = useState<number | null>(null);
|
||||
const [limits, setLimits] = useState<FeedbackLimits | null>(null);
|
||||
const list = useRef<HTMLUListElement>(null);
|
||||
const [said, setSaid] = useState("");
|
||||
const report = useRef(onUnread);
|
||||
@@ -168,7 +169,7 @@ export function FeedbackMine({ port, onFile, onUnread }: Props) {
|
||||
|
||||
useEffect(() => {
|
||||
let live = true;
|
||||
port.feedbackEnv(document.documentElement.lang).then((e) => live && setReplyBytes(e.limits.replyBytes)).catch(() => {});
|
||||
port.feedbackEnv(document.documentElement.lang).then((e) => live && setLimits(e.limits)).catch(() => {});
|
||||
return () => {
|
||||
live = false;
|
||||
};
|
||||
@@ -207,6 +208,8 @@ export function FeedbackMine({ port, onFile, onUnread }: Props) {
|
||||
mine !== null && mine.items.length > 0 && <p className="fbk-hint fbk-level-gone">{t("等级暂时无法显示。")}</p>
|
||||
)}
|
||||
|
||||
{mine && <FeedbackLink port={port} hasReport={mine.items.length > 0} />}
|
||||
|
||||
{mine?.offline && (
|
||||
<div className="fbk-note" role="status" data-tone="info">
|
||||
<StudioIcon name="warning" />
|
||||
@@ -274,7 +277,7 @@ export function FeedbackMine({ port, onFile, onUnread }: Props) {
|
||||
</ol>}
|
||||
<FeedbackReviewNote item={item} />
|
||||
<FeedbackThread item={item} fresh={fresh[item.receipt] ?? 0} onView={view} />
|
||||
<FeedbackReplyBox port={port} item={item} limit={replyBytes} offline={mine.offline} onSent={sent} onFile={onFile} onStale={load} />
|
||||
<FeedbackReplyBox port={port} item={item} limit={limits?.replyBytes ?? null} limits={limits} offline={mine.offline} onSent={sent} onFile={onFile} onStale={load} />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
|
||||
@@ -416,3 +416,64 @@ describe("review fixes", () => {
|
||||
expect(screen.queryByText("审核中")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("replying with screenshots", () => {
|
||||
const png = (name = "shot.png") => new File([new Uint8Array([0x89, 0x50, 0x4e, 0x47])], name, { type: "image/png" });
|
||||
const openBox = async (over: Partial<FeedbackItem> = {}) => {
|
||||
const port = portWith(() => mine([item({ status: "needs_info", needsInput: true, replies: [reply(1, "maintainer", "can you show it?")], ...over })]));
|
||||
render(<FeedbackMine port={port} onFile={() => {}} />);
|
||||
await screen.findByText("FB-AAAA-0001");
|
||||
await screen.findByRole("button", { name: "添加截图…" });
|
||||
return port;
|
||||
};
|
||||
const attach = (...files: File[]) => userEvent.upload(screen.getByLabelText("选择截图文件"), files);
|
||||
|
||||
it("sends the attached images with the reply and clears them afterwards", async () => {
|
||||
const port = await openBox();
|
||||
await attach(png("a.png"), png("b.png"));
|
||||
await screen.findByText("a.png");
|
||||
await userEvent.type(box(), "here it is");
|
||||
await userEvent.click(sendButton());
|
||||
await waitFor(() => expect(port.replyFeedback).toHaveBeenCalledTimes(1));
|
||||
const [receipt, body, images] = (port.replyFeedback as ReturnType<typeof vi.fn>).mock.calls[0]!;
|
||||
expect([receipt, body, images.map((i: { name: string }) => i.name)]).toEqual(["FB-AAAA-0001", "here it is", ["a.png", "b.png"]]);
|
||||
expect(images[0].dataBase64).not.toBe("");
|
||||
await waitFor(() => expect(screen.queryByText("a.png")).toBeNull());
|
||||
});
|
||||
|
||||
it("lets a picture be removed, and refuses more than the limit or a wrong type with the reason", async () => {
|
||||
await openBox();
|
||||
await attach(png("a.png"), png("b.png"), png("c.png"), png("d.png"));
|
||||
await screen.findByText("c.png");
|
||||
expect(screen.queryByText("d.png")).toBeNull();
|
||||
expect(document.querySelector(".fbk-refused")!.textContent).toContain("d.png:最多 3 张");
|
||||
await userEvent.click(screen.getByRole("button", { name: "移除截图 b.png" }));
|
||||
expect(screen.queryByText("b.png")).toBeNull();
|
||||
await userEvent.setup({ applyAccept: false }).upload(screen.getByLabelText("选择截图文件"), new File(["x"], "notes.txt", { type: "text/plain" }));
|
||||
expect(await screen.findByText(/notes\.txt:只支持 PNG 或 JPEG/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("keeps the draft and the pictures when the send fails", async () => {
|
||||
const port = await openBox();
|
||||
(port.replyFeedback as ReturnType<typeof vi.fn>).mockRejectedValueOnce(new HttpError(503, "down", { code: FEEDBACK_CODE.unavailable, error: "down" }));
|
||||
await attach(png("a.png"));
|
||||
await screen.findByText("a.png");
|
||||
await userEvent.type(box(), "retry me");
|
||||
await userEvent.click(sendButton());
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.getByText("a.png")).toBeTruthy();
|
||||
expect(box().value).toBe("retry me");
|
||||
});
|
||||
|
||||
it("tells the reporter the pictures go to the maintainers only, on a public report too", async () => {
|
||||
await openBox({ issueNumber: 42 });
|
||||
expect(screen.getByText(/截图先只发给维护者/)).toBeTruthy();
|
||||
});
|
||||
|
||||
it("sends a text-only reply exactly as before", async () => {
|
||||
const port = await openBox();
|
||||
await userEvent.type(box(), "just text");
|
||||
await userEvent.click(sendButton());
|
||||
await waitFor(() => expect(port.replyFeedback).toHaveBeenCalledWith("FB-AAAA-0001", "just text"));
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,9 @@
|
||||
import { useEffect, useId, useMemo, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useId, useMemo, useRef, useState, type ClipboardEvent } from "react";
|
||||
import { current, t } from "../i18n";
|
||||
import { FEEDBACK_CODE, FEEDBACK_REPO_ISSUES, FEEDBACK_REPLYABLE, isUnderReview, type FeedbackItem, type FeedbackReply } from "../port/feedback";
|
||||
import { FEEDBACK_CODE, FEEDBACK_REPO_ISSUES, FEEDBACK_REPLYABLE, isUnderReview, type FeedbackItem, type FeedbackLimits, type FeedbackReply } from "../port/feedback";
|
||||
import type { AgentPort } from "../port/port";
|
||||
import { replyFailure, type FeedbackFailure } from "./feedbackfailure";
|
||||
import { admit, megabytes, payload, readShot, REFUSED_LABEL, type Refused, type Shot } from "./feedbackshots";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
|
||||
const SHOWN = 4;
|
||||
@@ -91,19 +92,36 @@ interface ReplyProps {
|
||||
port: AgentPort;
|
||||
item: FeedbackItem;
|
||||
limit: number | null;
|
||||
limits?: Pick<FeedbackLimits, "images" | "uploadBytes"> | null;
|
||||
offline: boolean;
|
||||
onSent: (receipt: string) => void;
|
||||
onFile: (url: string) => void;
|
||||
onStale: () => void;
|
||||
}
|
||||
|
||||
export function FeedbackReplyBox({ port, item, limit, offline, onSent, onFile, onStale }: ReplyProps) {
|
||||
export function FeedbackReplyBox({ port, item, limit, limits = null, offline, onSent, onFile, onStale }: ReplyProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
const [text, setText] = useState("");
|
||||
const [sending, setSending] = useState(false);
|
||||
const [failure, setFailure] = useState<FeedbackFailure | null>(null);
|
||||
const opener = useRef<HTMLButtonElement>(null);
|
||||
const field = useRef<HTMLTextAreaElement>(null);
|
||||
const picker = useRef<HTMLInputElement>(null);
|
||||
const [shots, setShots] = useState<Shot[]>([]);
|
||||
const [refused, setRefused] = useState<Refused[]>([]);
|
||||
const held = useRef(shots);
|
||||
held.current = shots;
|
||||
const add = useCallback(
|
||||
async (files: File[]) => {
|
||||
if (!limits || files.length === 0) return;
|
||||
const { taken, refused: no } = admit(held.current.length, files, limits);
|
||||
setRefused(no);
|
||||
const read = await Promise.allSettled(taken.map(readShot));
|
||||
const ok = read.flatMap((r) => (r.status === "fulfilled" ? [r.value] : []));
|
||||
if (ok.length > 0) setShots((prev) => [...prev, ...ok].slice(0, limits.images));
|
||||
},
|
||||
[limits],
|
||||
);
|
||||
const replyable = !item.statusUnavailable && FEEDBACK_REPLYABLE.includes(item.status);
|
||||
if (!replyable) return null;
|
||||
const reopens = item.status === "fixed";
|
||||
@@ -120,8 +138,10 @@ export function FeedbackReplyBox({ port, item, limit, offline, onSent, onFile, o
|
||||
setSending(true);
|
||||
setFailure(null);
|
||||
try {
|
||||
await port.replyFeedback(item.receipt, text.trim());
|
||||
await (shots.length > 0 ? port.replyFeedback(item.receipt, text.trim(), payload(shots)) : port.replyFeedback(item.receipt, text.trim()));
|
||||
setText("");
|
||||
setShots([]);
|
||||
setRefused([]);
|
||||
setOpen(false);
|
||||
onSent(item.receipt);
|
||||
} catch (e) {
|
||||
@@ -149,6 +169,12 @@ export function FeedbackReplyBox({ port, item, limit, offline, onSent, onFile, o
|
||||
data-needs={item.needsInput ? "" : undefined}
|
||||
aria-busy={sending}
|
||||
noValidate
|
||||
onPaste={(e: ClipboardEvent<HTMLFormElement>) => {
|
||||
const files = [...e.clipboardData.files].filter((f) => f.type.startsWith("image/"));
|
||||
if (files.length === 0 || !limits) return;
|
||||
e.preventDefault();
|
||||
void add(files);
|
||||
}}
|
||||
data-action-submit="feedback.reply.send"
|
||||
onSubmit={(e) => { e.preventDefault(); void send(); }}
|
||||
>
|
||||
@@ -175,6 +201,41 @@ export function FeedbackReplyBox({ port, item, limit, offline, onSent, onFile, o
|
||||
{t("{n} / {max} 字节", { n: bytes.toLocaleString(), max: max.toLocaleString() })}
|
||||
</span>
|
||||
</div>
|
||||
{limits && (
|
||||
<div className="fbk-reply-shots">
|
||||
{shots.length > 0 && (
|
||||
<ul className="fbk-shots">
|
||||
{shots.map((s) => (
|
||||
<li key={s.id}>
|
||||
<img src={s.preview} alt={s.name} />
|
||||
<span className="fbk-shot-name">{s.name}</span>
|
||||
<button type="button" className="fbk-shot-x" data-action="feedback.shot.remove" data-target={s.id} disabled={sending} aria-label={t("移除截图 {name}", { name: s.name })} onClick={() => setShots((prev) => prev.filter((x) => x.id !== s.id))}>
|
||||
<StudioIcon name="close" />
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
<div className="fbk-drop-line">
|
||||
<button type="button" className="btn sm" data-action="feedback.shot.pick" disabled={sending || shots.length >= limits.images} onClick={() => picker.current?.click()}>
|
||||
<StudioIcon name="plus" />
|
||||
{t("添加截图…")}
|
||||
</button>
|
||||
<span className="fbk-hint">
|
||||
{t("也可以直接粘贴。PNG 或 JPEG,最多 {n} 张,每张不超过 {size} MB。", { n: limits.images, size: megabytes(limits.uploadBytes) })}{" "}
|
||||
{t("截图先只发给维护者,由维护者决定是否公开。")}
|
||||
</span>
|
||||
</div>
|
||||
<input ref={picker} className="sr-only" type="file" accept="image/png,image/jpeg" multiple tabIndex={-1} aria-label={t("选择截图文件")} data-action="feedback.shot.pick" onChange={(e) => { void add([...(e.target.files ?? [])]); e.target.value = ""; }} />
|
||||
{refused.length > 0 && (
|
||||
<ul className="fbk-refused" role="status">
|
||||
{refused.map((r, i) => (
|
||||
<li key={i}>{t(REFUSED_LABEL[r.why], { name: r.name, size: megabytes(limits.uploadBytes), n: limits.images })}</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{failure && (
|
||||
<div className="fbk-note" role="alert" data-tone="error" data-code={failure.code}>
|
||||
<StudioIcon name="warning" />
|
||||
|
||||
@@ -14,6 +14,7 @@ const PARTS: [Fold, string, string][] = [
|
||||
|
||||
const MODE: Record<FoldMode, string> = {
|
||||
folded: "收起",
|
||||
peek: "收起并预览首句",
|
||||
live: "进行中展开",
|
||||
failed: "失败时展开",
|
||||
changed: "改动时展开",
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { cleanup, fireEvent, render, screen, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { MeterRail } from "./MeterRail";
|
||||
import { boot, STORAGE } from "../i18n";
|
||||
import type { SessionUsage } from "../port/port";
|
||||
import type { CostQuote } from "../port/wire";
|
||||
|
||||
beforeEach(() => {
|
||||
localStorage.setItem(STORAGE, "zh");
|
||||
boot();
|
||||
});
|
||||
afterEach(cleanup);
|
||||
|
||||
const quote = (over: Partial<CostQuote> = {}): CostQuote => ({ original: { amount: "12.5", currency: "CNY" }, estimated: false, costComplete: true, coverage: "complete", ...over } as CostQuote);
|
||||
|
||||
const usage: SessionUsage = {
|
||||
requests: 3, promptTokens: 8000, completionTokens: 450, cacheHit: 2300, cacheMiss: 5700, cacheUnknown: 5000, workMs: 72_000,
|
||||
sources: [
|
||||
{ source: "compaction", requests: 1, promptTokens: 5000, completionTokens: 300, cacheHit: 0, cacheMiss: 5000, cacheUnknown: 5000, cost: quote({ coverage: "incomplete", incompleteReason: "no_rate" }) },
|
||||
{ source: "executor", requests: 2, promptTokens: 3000, completionTokens: 150, cacheHit: 2300, cacheMiss: 700, cost: quote({ original: { amount: "3.2", currency: "CNY" } }) },
|
||||
],
|
||||
};
|
||||
|
||||
function rail(over: { usage?: SessionUsage; quote?: CostQuote; hideAmounts?: boolean } = {}) {
|
||||
return render(
|
||||
<MeterRail
|
||||
tps={0} trail={[]} running={false} speed={{ output: 0, modelSeconds: 0, average: 0, firstToken: 0 }}
|
||||
metrics={{ hit: 0, miss: 0 }} ctx={null} mcp={[]} compact={{ phase: "idle" } as never} cost="" wallet={{} as never}
|
||||
hideAmounts={over.hideAmounts ?? false} tasks={[]} jobs={[]} onSettings={vi.fn()} onCancelJob={vi.fn(async () => {})}
|
||||
usage={"usage" in over ? over.usage : usage} quote={"quote" in over ? over.quote : quote()}
|
||||
/>,
|
||||
);
|
||||
}
|
||||
|
||||
const button = () => screen.getByRole("button", { name: "查看会话概览" });
|
||||
const card = () => screen.getByRole("dialog", { name: "会话概览" });
|
||||
|
||||
describe("the session overview behind the cache meter", () => {
|
||||
it("opens from the cache figure and closes on Escape and on a press away", async () => {
|
||||
const user = userEvent.setup();
|
||||
rail();
|
||||
expect(button().getAttribute("aria-expanded")).toBe("false");
|
||||
await user.click(button());
|
||||
expect(button().getAttribute("aria-expanded")).toBe("true");
|
||||
await user.keyboard("{Escape}");
|
||||
expect(button().getAttribute("aria-expanded")).toBe("false");
|
||||
await user.click(button());
|
||||
fireEvent.mouseDown(document.body);
|
||||
expect(button().getAttribute("aria-expanded")).toBe("false");
|
||||
});
|
||||
|
||||
it("is reachable from the keyboard", async () => {
|
||||
const user = userEvent.setup();
|
||||
rail();
|
||||
button().focus();
|
||||
await user.keyboard("{Enter}");
|
||||
expect(button().getAttribute("aria-expanded")).toBe("true");
|
||||
});
|
||||
|
||||
it("says what each session figure counts, beside the figure", () => {
|
||||
rail();
|
||||
const view = within(card());
|
||||
expect(view.getByText("自本次打开此会话起;恢复或分叉会重新计数")).toBeTruthy();
|
||||
expect(view.getByText("请求").closest("div")!.textContent).toContain("3");
|
||||
expect(view.getByText("请求").closest("div")!.textContent).toContain("返回了用量与价格的模型响应");
|
||||
expect(view.getByText("Token").closest("div")!.textContent).toContain("8.5k");
|
||||
expect(view.getByText("工作时长").closest("div")!.textContent).toContain("1 分 12 秒");
|
||||
expect(view.getByText("工作时长").closest("div")!.textContent).toContain("含审批与工具等待");
|
||||
});
|
||||
|
||||
it("states the hit rate over what the endpoint split, and says what it left out", () => {
|
||||
rail();
|
||||
const row = within(card()).getByText("缓存命中").closest("div")!;
|
||||
expect(row.textContent).toContain("77%");
|
||||
expect(row.textContent).toContain("不含服务商未区分的 5k tokens");
|
||||
});
|
||||
|
||||
it("lists each source with its own cache and cost, and an unpriced one as unknown", () => {
|
||||
rail();
|
||||
const rows = [...card().querySelectorAll(".studio-overview-row")].map((r) => r.textContent);
|
||||
expect(rows[0]).toContain("压缩");
|
||||
expect(rows[0]).toContain("—");
|
||||
expect(rows[1]).toContain("主代理");
|
||||
expect(rows[1]).toContain("77%");
|
||||
expect(rows[1]).toContain("¥3.20");
|
||||
const price = card().querySelectorAll(".studio-overview-row")[0]!.querySelector("b[title]");
|
||||
expect(price?.getAttribute("title")).toBe("价格未知:no_rate");
|
||||
});
|
||||
|
||||
it("never shows a figure as exact for a cost that was only partly priced", () => {
|
||||
rail({ quote: quote({ coverage: "partial" }) });
|
||||
const row = within(card()).getByText("费用").closest("div")!;
|
||||
expect(row.textContent).toContain("≥");
|
||||
expect(row.textContent).toContain("部分请求没有价格信息");
|
||||
});
|
||||
|
||||
it("gives no amount for an unpriced session, and keeps the reason", () => {
|
||||
rail({ quote: quote({ coverage: "incomplete", incompleteReason: "no_rate" }) });
|
||||
const row = within(card()).getByText("费用").closest("div")!;
|
||||
expect(row.textContent).not.toMatch(/¥|\d\.\d\d/);
|
||||
expect(row.textContent).toContain("价格未知:no_rate");
|
||||
});
|
||||
|
||||
it("hides the amounts when the person hid them, and says so in no digits", () => {
|
||||
rail({ hideAmounts: true });
|
||||
expect(card().textContent).not.toContain("¥3.20");
|
||||
expect(card().textContent).not.toContain("12.50");
|
||||
});
|
||||
|
||||
it("says there is nothing to count yet when no request was billed", () => {
|
||||
rail({ usage: { ...usage, requests: 0, sources: [] } });
|
||||
expect(within(card()).getByText("这个会话还没有计费的请求")).toBeTruthy();
|
||||
});
|
||||
|
||||
it("agrees with the card about the number it shows on the meter", () => {
|
||||
rail();
|
||||
expect(button().textContent).toContain("77%");
|
||||
});
|
||||
});
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useCallback, useId, useRef, useState, type CSSProperties } from "react";
|
||||
import { t } from "../i18n";
|
||||
import type { ContextBreakdown, JobEntry, McpEntry } from "../port/port";
|
||||
import type { ContextBreakdown, JobEntry, McpEntry, SessionUsage } from "../port/port";
|
||||
import type { CostQuote } from "../port/wire";
|
||||
import { cacheRate as rateOf } from "../state/overview";
|
||||
import { SessionOverviewCard } from "./SessionOverviewCard";
|
||||
import type { Metrics } from "../state/session_types";
|
||||
import { DeckChips, type Deck } from "./DeckChips";
|
||||
import { ContextSummaryCard } from "./ContextSummaryCard";
|
||||
@@ -14,7 +17,7 @@ import { MASK, type Wallet } from "./wallet";
|
||||
import type { SpeedSummary } from "./speed";
|
||||
|
||||
/** The numbers about this turn, in one row under the composer. */
|
||||
export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compact, cost, wallet, hideAmounts, tasks, jobs, onSettings, onCancelJob }: {
|
||||
export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compact, cost, wallet, hideAmounts, tasks, jobs, onSettings, onCancelJob, usage, quote }: {
|
||||
tps: number;
|
||||
trail: number[];
|
||||
running: boolean;
|
||||
@@ -30,6 +33,8 @@ export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compa
|
||||
jobs: JobEntry[];
|
||||
onSettings: (section?: string) => void;
|
||||
onCancelJob: (id: string) => Promise<void>;
|
||||
usage?: SessionUsage;
|
||||
quote?: CostQuote;
|
||||
}) {
|
||||
const speedDetailId = useId();
|
||||
const [meterOpen, setMeterOpen] = useState(false);
|
||||
@@ -37,8 +42,14 @@ export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compa
|
||||
const closeMeter = useCallback(() => setMeterOpen(false), []);
|
||||
useDismiss(meterOpen, meterRef, closeMeter);
|
||||
const [deck, setDeck] = useState<Deck>("");
|
||||
const cacheTokens = metrics.hit + metrics.miss;
|
||||
const cacheRate = cacheTokens > 0 ? Math.round((metrics.hit / cacheTokens) * 100) : null;
|
||||
const [overviewOpen, setOverviewOpen] = useState(false);
|
||||
const overviewRef = useRef<HTMLDivElement>(null);
|
||||
const closeOverview = useCallback(() => setOverviewOpen(false), []);
|
||||
useDismiss(overviewOpen, overviewRef, closeOverview);
|
||||
// The same rate the overview states: from the ledger when the kernel sends it,
|
||||
// so the meter and the card cannot disagree about one number.
|
||||
const ratio = usage && usage.requests > 0 ? rateOf(usage.cacheHit, usage.cacheMiss, usage.cacheUnknown ?? 0) : rateOf(metrics.hit, metrics.miss);
|
||||
const cacheRate = ratio === null ? null : Math.round(ratio * 100);
|
||||
const contextPercent = ctx && ctx.window > 0 ? Math.min(100, Math.round((ctx.used / ctx.window) * 100)) : 0;
|
||||
return (
|
||||
<div className="studio-meterrail" ref={meterRef} aria-label={t("运行统计")}>
|
||||
@@ -64,12 +75,15 @@ export function MeterRail({ tps, trail, running, speed, metrics, ctx, mcp, compa
|
||||
<p>{t("当前速度按最近 4 秒流式文本估算;整轮平均使用服务商返回的输出 Token 除以模型回合耗时。")}</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="studio-meter-static studio-meter-cache" title={cacheRate === null ? t("尚无缓存数据") : t("命中 {hit} · 未命中 {miss}", { hit: metrics.hit.toLocaleString(), miss: metrics.miss.toLocaleString() })}>
|
||||
<span>{t("缓存")}</span><b>{cacheRate === null ? "—" : `${cacheRate}%`}</b>
|
||||
</span>
|
||||
<div className="studio-overview-anchor" ref={overviewRef} data-open={overviewOpen ? "" : undefined}>
|
||||
<button className="studio-meter-cache" type="button" data-action="metrics.details" data-value="overview" aria-expanded={overviewOpen} aria-haspopup="dialog" aria-label={t("查看会话概览")} title={cacheRate === null ? t("尚无缓存数据") : t("命中 {hit} · 未命中 {miss}", { hit: metrics.hit.toLocaleString(), miss: metrics.miss.toLocaleString() })} onClick={() => { setMeterOpen(false); setOverviewOpen((open) => !open); }}>
|
||||
<span>{t("缓存")}</span><b>{cacheRate === null ? "—" : `${cacheRate}%`}</b>
|
||||
</button>
|
||||
<SessionOverviewCard className="studio-overview-popover" usage={usage} quote={quote} hideAmounts={hideAmounts} />
|
||||
</div>
|
||||
{ctx && ctx.window > 0 && (
|
||||
<div className="studio-context-anchor" data-open={meterOpen ? "" : undefined}>
|
||||
<button className="studio-meter-context" data-action="metrics.details" data-value="context" aria-expanded={meterOpen} aria-haspopup="dialog" aria-label={t("查看上下文与压缩")} onClick={() => setMeterOpen((open) => !open)}>
|
||||
<button className="studio-meter-context" data-action="metrics.details" data-value="context" aria-expanded={meterOpen} aria-haspopup="dialog" aria-label={t("查看上下文与压缩")} onClick={() => { setOverviewOpen(false); setMeterOpen((open) => !open); }}>
|
||||
<span className="studio-context-ring" style={{ "--fill": `${contextPercent}%` } as CSSProperties} aria-hidden="true" />
|
||||
<span>{t("上下文")}</span><b>{contextPercent}%</b>
|
||||
</button>
|
||||
|
||||
@@ -82,7 +82,7 @@ it.each(["enter", "button"])("allows adding an exact wire ID that matches anothe
|
||||
render(<EditConn entry={entry} port={port} busy="" setBusy={() => {}} onDone={() => {}} onRevert={() => {}} />);
|
||||
const search = screen.getByRole("searchbox", { name: "搜索或添加模型" });
|
||||
await userEvent.type(search, "beta");
|
||||
expect(screen.getByText("vendor/long-v1")).toBeTruthy();
|
||||
expect(screen.getAllByText("vendor/long-v1").length).toBeGreaterThan(0);
|
||||
const add = screen.getByRole("button", { name: /添加未列出的模型「beta」/ });
|
||||
if (method === "enter") await userEvent.type(search, "{enter}");
|
||||
else await userEvent.click(add);
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { incomplete, decimal, priceTextOf, pricesToSend } from "./ModelPrices";
|
||||
|
||||
const stored = { alpha: { input: 3, output: 15, cacheHit: 0.3, currency: "USD" } };
|
||||
|
||||
describe("what a price row sends", () => {
|
||||
it("sends a complete row, with the input price as the cached price when that is blank", () => {
|
||||
expect(pricesToSend(["a"], { a: { inp: "1.5", out: "6", hit: "", cur: "CNY" } }, {})).toEqual({ a: { input: 1.5, output: 6, cacheHit: 1.5, currency: "CNY" } });
|
||||
expect(pricesToSend(["a"], { a: { inp: "1.5", out: "6", hit: "0.15", cur: "USD" } }, {})).toEqual({ a: { input: 1.5, output: 6, cacheHit: 0.15, currency: "USD" } });
|
||||
});
|
||||
|
||||
it("keeps zero as a price: free is not unknown", () => {
|
||||
expect(pricesToSend(["a"], { a: { inp: "0", out: "0", hit: "", cur: "CNY" } }, {})).toEqual({ a: { input: 0, output: 0, cacheHit: 0, currency: "CNY" } });
|
||||
});
|
||||
|
||||
it("clears a stored price when the row is emptied, and sends nothing for a model that never had one", () => {
|
||||
expect(pricesToSend(["alpha"], { alpha: { inp: "", out: "", hit: "", cur: "USD" } }, stored)).toEqual({ alpha: null });
|
||||
expect(pricesToSend(["beta"], { beta: { inp: "", out: "", hit: "", cur: "CNY" } }, stored)).toEqual({});
|
||||
});
|
||||
|
||||
it("changes nothing for a half-finished row", () => {
|
||||
const half = { alpha: { inp: "4", out: "", hit: "", cur: "USD" } };
|
||||
expect(incomplete(half.alpha)).toBe(true);
|
||||
expect(pricesToSend(["alpha"], half, stored)).toEqual({ alpha: stored.alpha });
|
||||
expect(pricesToSend(["beta"], { beta: { inp: "4", out: "", hit: "", cur: "USD" } }, stored)).toEqual({});
|
||||
});
|
||||
|
||||
it("reads a stored rate back into the same row, leaving the cached field blank when it equals the input price", () => {
|
||||
expect(priceTextOf({ input: 2, output: 8, cacheHit: 2, currency: "CNY" })).toEqual({ inp: "2", out: "8", hit: "", cur: "CNY" });
|
||||
expect(priceTextOf({ input: 2, output: 8, cacheHit: 0.2, currency: "USD" })).toEqual({ inp: "2", out: "8", hit: "0.2", cur: "USD" });
|
||||
});
|
||||
|
||||
it("types only digits and one point", () => {
|
||||
expect(decimal("-1.2.3e5")).toBe("1.235");
|
||||
expect(decimal("abc")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,103 @@
|
||||
import { t } from "../i18n";
|
||||
import type { ModelRate } from "../port/port";
|
||||
import { shortId } from "./ModelLimits";
|
||||
|
||||
export interface PriceText {
|
||||
inp: string;
|
||||
out: string;
|
||||
hit: string;
|
||||
cur: string;
|
||||
}
|
||||
|
||||
const CURRENCIES = ["CNY", "USD"] as const;
|
||||
const blank = (cur = "CNY"): PriceText => ({ inp: "", out: "", hit: "", cur });
|
||||
const typed = (n: number) => String(n);
|
||||
|
||||
export function priceTextOf(own: ModelRate | undefined): PriceText {
|
||||
return own ? { inp: typed(own.input), out: typed(own.output), hit: own.cacheHit === own.input ? "" : typed(own.cacheHit), cur: own.currency || "CNY" } : blank();
|
||||
}
|
||||
|
||||
// Digits and at most one point: a price is not an integer, and a sign or an
|
||||
// exponent is how a typo becomes a rate nobody meant.
|
||||
export const decimal = (s: string) => {
|
||||
const cleaned = s.replace(/[^\d.]/g, "");
|
||||
const at = cleaned.indexOf(".");
|
||||
return at < 0 ? cleaned : cleaned.slice(0, at + 1) + cleaned.slice(at + 1).replace(/\./g, "");
|
||||
};
|
||||
|
||||
const parsed = (s: string) => (s === "" || s === "." ? null : Number(s));
|
||||
|
||||
// A row is a price once input and output are both typed; one of them alone is a
|
||||
// half-finished row, which is neither a price nor an empty one.
|
||||
export const incomplete = (row: PriceText | undefined) => !!row && (row.inp === "") !== (row.out === "");
|
||||
|
||||
// An empty row clears the model's own price; an incomplete one changes nothing.
|
||||
// A blank cached-input price is the input price: no discount is assumed.
|
||||
export function pricesToSend(picked: string[], text: Record<string, PriceText>, stored: Record<string, ModelRate>): Record<string, ModelRate | null> {
|
||||
const out: Record<string, ModelRate | null> = {};
|
||||
for (const model of picked) {
|
||||
const row = text[model] ?? blank();
|
||||
if (incomplete(row)) {
|
||||
if (stored[model]) out[model] = stored[model];
|
||||
continue;
|
||||
}
|
||||
const input = parsed(row.inp);
|
||||
const output = parsed(row.out);
|
||||
if (input === null || output === null) {
|
||||
if (stored[model]) out[model] = null;
|
||||
continue;
|
||||
}
|
||||
out[model] = { input, output, cacheHit: parsed(row.hit) ?? input, currency: row.cur };
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function ModelPrices({
|
||||
models, value, onChange,
|
||||
}: {
|
||||
models: string[];
|
||||
value: Record<string, PriceText>;
|
||||
onChange: (next: Record<string, PriceText>) => void;
|
||||
}) {
|
||||
const set = (model: string, patch: Partial<PriceText>) =>
|
||||
onChange({ ...value, [model]: { ...(value[model] ?? blank()), ...patch } });
|
||||
return (
|
||||
<div className="ml-group mp-group" role="group" aria-label={t("按模型设置价格")}>
|
||||
<div className="ml-head">
|
||||
<strong>{t("按模型设置价格")}</strong>
|
||||
<span>{t("每百万 token 的单价。留空表示未知,用量页会如实显示「未知」,而不是 0;填 0 表示免费。缓存命中价留空时按输入价计算。")}</span>
|
||||
</div>
|
||||
<ul className="me-list ml-list">
|
||||
{models.map((model) => {
|
||||
const cur = value[model] ?? blank();
|
||||
const own = cur.inp !== "" || cur.out !== "";
|
||||
return (
|
||||
<li key={model} className="ml-row mp-row" data-model={model} data-own={own || undefined}>
|
||||
<span className="me-name" title={model}>{shortId(model)}</span>
|
||||
<span className="unit-field">
|
||||
<input data-action="provider.draft" data-value="model-price-input" aria-label={t("{model} 的输入单价", { model })}
|
||||
aria-invalid={incomplete(cur) && cur.inp === "" ? true : undefined} inputMode="decimal" value={cur.inp} placeholder={t("未知")}
|
||||
onChange={(e) => set(model, { inp: decimal(e.target.value) })} /><i>{t("输入")}</i>
|
||||
</span>
|
||||
<span className="unit-field">
|
||||
<input data-action="provider.draft" data-value="model-price-output" aria-label={t("{model} 的输出单价", { model })}
|
||||
aria-invalid={incomplete(cur) && cur.out === "" ? true : undefined} inputMode="decimal" value={cur.out} placeholder={t("未知")}
|
||||
onChange={(e) => set(model, { out: decimal(e.target.value) })} /><i>{t("输出")}</i>
|
||||
</span>
|
||||
<span className="unit-field">
|
||||
<input data-action="provider.draft" data-value="model-price-cache" aria-label={t("{model} 的缓存命中单价", { model })}
|
||||
inputMode="decimal" value={cur.hit} placeholder={t("同输入价")}
|
||||
onChange={(e) => set(model, { hit: decimal(e.target.value) })} /><i>{t("缓存命中")}</i>
|
||||
</span>
|
||||
<select data-action="provider.draft" data-value="model-price-currency" aria-label={t("{model} 的货币", { model })} value={cur.cur}
|
||||
onChange={(e) => set(model, { cur: e.target.value })}>
|
||||
{CURRENCIES.map((c) => <option key={c} value={c}>{c}</option>)}
|
||||
</select>
|
||||
{incomplete(cur) && <span className="why">{t("输入价和输出价需要都填写,才会保存这一行。")}</span>}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { t } from "../i18n";
|
||||
import { FeedbackBadge } from "./feedbackentry";
|
||||
import { ICON, type Section } from "./prefsnav";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
import { UpdateDot } from "./UpdateDot";
|
||||
|
||||
// Settings entries share their destination's mark with prefsnav.
|
||||
const CHAT = "M3.2 4.4a1.2 1.2 0 0 1 1.2-1.2h7.2a1.2 1.2 0 0 1 1.2 1.2v5a1.2 1.2 0 0 1-1.2 1.2H7.2L4.4 13v-2.4a1.2 1.2 0 0 1-1.2-1.2Z";
|
||||
@@ -53,6 +54,7 @@ export function Nav({ at, onGo, onFiles, onHome, onRail, onFeedback, onNew, feed
|
||||
{key === "files" ? <StudioIcon name="file" aria-hidden="true" /> : <svg viewBox="0 0 16 16" aria-hidden="true">
|
||||
{key === "chat" ? <path d={CHAT} /> : ICON[key]}
|
||||
</svg>}
|
||||
{key === "advanced" && <UpdateDot />}
|
||||
<span className="navhint" role="tooltip">{t(label)}</span>
|
||||
</button>
|
||||
);
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
// @vitest-environment jsdom
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import "./testkit";
|
||||
import { OpenWithMenu } from "./OpenWithMenu";
|
||||
|
||||
const shell = vi.hoisted(() => ({ reveals: true }));
|
||||
vi.mock("../port/host", () => ({ host: () => ({ revealsFiles: () => shell.reveals }) }));
|
||||
|
||||
afterEach(cleanup);
|
||||
|
||||
const targets = [
|
||||
{ id: "vscode", name: "VS Code", available: true },
|
||||
{ id: "terminal", name: "终端", available: true },
|
||||
{ id: "sublime", name: "Sublime Text", available: false },
|
||||
];
|
||||
|
||||
function draw(reveals = true) {
|
||||
shell.reveals = reveals;
|
||||
const openWith = vi.fn().mockResolvedValue(undefined);
|
||||
const openWithTargets = vi.fn().mockResolvedValue(targets);
|
||||
const onError = vi.fn();
|
||||
render(<OpenWithMenu hub={{ openWith, openWithTargets }} root="/w" onError={onError} />);
|
||||
return { openWith, openWithTargets, onError };
|
||||
}
|
||||
|
||||
it("lists the programs installed on this machine and opens the project in the chosen one", async () => {
|
||||
const { openWith, openWithTargets } = draw();
|
||||
expect(openWithTargets).not.toHaveBeenCalled();
|
||||
await userEvent.click(screen.getByRole("button", { name: "用其他程序打开项目" }));
|
||||
await waitFor(() => expect(screen.getByRole("menuitem", { name: "用 VS Code 打开" })).toBeTruthy());
|
||||
expect(screen.getByRole("menuitem", { name: "用 终端 打开" })).toBeTruthy();
|
||||
expect(screen.queryByRole("menuitem", { name: /Sublime/ })).toBeNull();
|
||||
await userEvent.click(screen.getByRole("menuitem", { name: "用 终端 打开" }));
|
||||
expect(openWith).toHaveBeenCalledExactlyOnceWith("/w", "terminal");
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
});
|
||||
|
||||
it("says so through the error path when the program could not be started", async () => {
|
||||
const { openWith, onError } = draw();
|
||||
openWith.mockRejectedValueOnce(new Error("not installed"));
|
||||
await userEvent.click(screen.getByRole("button", { name: "用其他程序打开项目" }));
|
||||
await userEvent.click(await screen.findByRole("menuitem", { name: "用 VS Code 打开" }));
|
||||
await waitFor(() => expect(onError).toHaveBeenCalled());
|
||||
});
|
||||
|
||||
it("closes on Escape and offers nothing where the kernel's machine cannot open programs", async () => {
|
||||
draw();
|
||||
await userEvent.click(screen.getByRole("button", { name: "用其他程序打开项目" }));
|
||||
await screen.findByRole("menu");
|
||||
await userEvent.keyboard("{Escape}");
|
||||
expect(screen.queryByRole("menu")).toBeNull();
|
||||
cleanup();
|
||||
draw(false);
|
||||
expect(screen.queryByRole("button", { name: "用其他程序打开项目" })).toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { openWithName } from "./openwith_label";
|
||||
import type { HubPort, OpenWithTarget } from "../port/hub";
|
||||
import { host } from "../port/host";
|
||||
import { t } from "../i18n";
|
||||
import { StudioIcon } from "./StudioIcon";
|
||||
|
||||
// The programs this machine can open the project in. Which exist is the kernel's
|
||||
// table, asked when the menu opens: what is installed can change while the window
|
||||
// stays open. Offered only where the kernel's own machine can start programs.
|
||||
export function OpenWithMenu({ hub, root, onError }: {
|
||||
hub: Pick<HubPort, "openWith" | "openWithTargets">;
|
||||
root: string;
|
||||
onError: (e: unknown) => void;
|
||||
}) {
|
||||
const shell = host().revealsFiles();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [targets, setTargets] = useState<OpenWithTarget[]>([]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
let alive = true;
|
||||
void hub.openWithTargets().then((list) => alive && setTargets(list.filter((x) => x.available))).catch(() => {});
|
||||
return () => { alive = false; };
|
||||
}, [hub, open]);
|
||||
if (!shell || !root) return null;
|
||||
return (
|
||||
<div
|
||||
className="openwith"
|
||||
data-action-keydown="layer.dismiss"
|
||||
onKeyDown={(e) => { if (e.key === "Escape" && open) { e.stopPropagation(); setOpen(false); } }}
|
||||
onBlur={(e) => { if (!e.currentTarget.contains(e.relatedTarget as Node | null)) setOpen(false); }}
|
||||
>
|
||||
<button
|
||||
className="openwith-btn"
|
||||
data-action="workbench.open-with-menu"
|
||||
aria-haspopup="menu"
|
||||
aria-expanded={open}
|
||||
aria-label={t("用其他程序打开项目")}
|
||||
title={t("用其他程序打开项目")}
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
>
|
||||
<StudioIcon name="external" /><StudioIcon name="down" />
|
||||
</button>
|
||||
{open && (
|
||||
<div className="openwith-menu" role="menu" aria-label={t("用其他程序打开项目")}>
|
||||
{targets.length === 0 && <small>{t("没有找到可用的程序")}</small>}
|
||||
{targets.map((target) => (
|
||||
<button
|
||||
key={target.id}
|
||||
role="menuitem"
|
||||
data-action="workspace.open-with"
|
||||
data-target={root}
|
||||
data-value={target.id}
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
hub.openWith(root, target.id).catch(onError);
|
||||
}}
|
||||
>
|
||||
<span>{t("用 {name} 打开", { name: openWithName(target) })}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loaded 100 of 459 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user