Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions Agent.md
Original file line numberDiff line numberDiff line change
Expand Up@@ -85,8 +85,8 @@ Usage: say "tool loop" for the whole process, "round N" for a single LLM request
- Streaming chat with delta rendering (16ms batching), markdown on done (marked + DOMPurify + local highlight.js subset), tool call status cards (2000-char truncation + expand)
- Session list/switch/new/delete + right-click rename (context menu, #423) synced with daemon; own-stream busy lock (G65); broadcast streams from other clients tagged "来自其他客户端"
- Disconnect/reconnect: red status dot, auto daemon respawn (stale-port detection), session resume, input bar restored on disconnect (no 30s fake-timeout)
- Unit tests `npm test` (262: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- React migration Batch 0 (design `~/.emrg/designs/gui-react-migration-design.md`): `emrg/gui/renderer/` scaffold — Vite 8 + React 19 + TypeScript strict + Vitest + self-developed SnapshotStore (useSyncExternalStore); standalone build → `renderer/dist/index.html` + `assets/*` (CSP `script-src 'self'`-safe, no inline); `window.emrg` bridge (46 methods) + main.js + daemon protocol untouched; vanilla renderer stays live until Batch 5 switch (D3)
- Unit tests `npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api); RESPONSE_TYPES mirror daemon protocol verified against `daemon.py`
- React migration Batch 0 + Batch 1 (design `~/.emrg/designs/gui-react-migration-design.md`): `emrg/gui/renderer/` scaffold — Vite 8 + React 19 + TypeScript strict + Vitest + self-developed SnapshotStore (useSyncExternalStore); standalone build → `renderer/dist/index.html` + `assets/*` (CSP `script-src 'self'`-safe, no inline); `window.emrg` bridge (52 invoke + onEvent, API-surface guard test) + main.js + daemon protocol untouched; vanilla renderer stays live until Batch 5 switch (D3); Batch 1 ported commands.js → `lib/commands.ts` + copywriting.js → `lib/copywriting.ts` (pure logic, injectable t)
- **Scheduled tasks** — Task generalization + CRUD (rant 2026-08-12T18:23:15, #709/#710/#711)
- Task handler generalized: `TaskHandler` (renamed from `EvolutionHandler`), repo-configured self-heal for any project, template lookup builtin → `~/.emrg/task-templates/<name>.md` → fallback
- Daemon commands: `task_create/update/delete` + `task_template_create/list/update/delete` (tasks stored in `~/.emrg/tasks.yml`, custom type templates in `~/.emrg/task-templates/`)
Expand DownExpand Up@@ -120,7 +120,7 @@ pkill -f "emrg.server"; rm -f ~/.emrg/emrgd.token; python -m emrg
```

Python: `uv run pytest tests/ -v` (1095) — import check: `uv run python -c "from emrg.client.app import run_client"`
GUI: `cd emrg/gui && npm test` (262: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`; renderer React suite (Batch 0): `cd emrg/gui/renderer && npm run typecheck && npm test` (19 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke) + `npm run build` → `renderer/dist/`
GUI: `cd emrg/gui && npm test` (265: 45 daemon_client + 20 conn-manager + 22 app-commands + 132 renderer smoke + 15 i18n + 8 integration + 3 commands + 8 build-config + 7 gui-state + 2 tool-group + 3 preload-api) — syntax: `node --check main.js preload.js daemon_client.js renderer/js/*.js`; renderer React suite: `cd emrg/gui/renderer && npm run typecheck && npm test` (34 vitest: 5 snapshot-store + 9 utils + 3 ErrorBoundary + 2 App smoke + 11 commands + 4 copywriting) + `npm run build` → `renderer/dist/`
CI: `uv run pytest` (ubuntu + **windows-2025 matrix** — Windows pytest 回归在 PR CI 即失败,v0.2.29 教训 #725) + GUI tests + **actionlint workflow lint** (`rhysd/actionlint@v1.7.12` gate, #444 — workflow 解析错误在 PR CI 即失败,如 `if:` secrets 上下文)
Re-trigger: `scripts/re-trigger-ci.sh [branch]` (workflow_dispatch, #527 — 替代空 commit 重触发:Actions outage 会整段丢弃 push 事件,dispatch 走 API 路径不受影响)
Git-over-https 兜底: `python scripts/sync-master-from-api.py [--repo owner/name] [--ref master]` — 受限网络下 github.com:443 不可达而 api.github.com 可达时,用 Git Data API 的 verification payload + signature 字节级重建上游 commit(含 web-flow GPG 签名 squash merge,reconstruct_commit 经 hermetic 测试验证 sha 一致)并推进本地 refs;内容对象缺失时 fail-loud 提示改用 git fetch(10+ 周期实证的恢复路径)
Expand Down
77 changes: 77 additions & 0 deletions emrg/gui/renderer/src/lib/commands.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
import { describe, expect, it } from "vitest";
import { COMMANDS, getCompletions, hintText, parseInput } from "./commands";
import type { TranslateFn } from "./utils";

/** hint 键 → 固定文案的测试翻译函数(i18n 未就绪路径的替身) */
const fakeT: TranslateFn = (key) => `[${key}]`;

describe("commands registry", () => {
it("contains all 15 TUI commands + /open (rant 19:44 acceptance + P5 extension)", () => {
const expected = [
"/clear", "/compact", "/delete", "/help", "/image", "/memory", "/model",
"/open", "/rant", "/rename", "/resume", "/rewind", "/sessions", "/skills", "/trigger", "/version",
];
for (const cmd of expected) {
expect(COMMANDS[cmd]).toBeDefined();
}
expect(Object.keys(COMMANDS).length).toBe(16);
// 每条指令都有 hint + phase 1-4
for (const [cmd, meta] of Object.entries(COMMANDS)) {
expect(meta.hint.length).toBeGreaterThan(0);
expect(meta.phase).toBeGreaterThanOrEqual(1);
expect(meta.phase).toBeLessThanOrEqual(4);
}
});
});

describe("parseInput", () => {
it("plain message", () => {
expect(parseInput("你好,帮我写周报")).toEqual({ type: "message" });
expect(parseInput(" 你好 ")).toEqual({ type: "message" });
});
it("known command (case-insensitive, with args)", () => {
expect(parseInput("/clear")).toEqual({ type: "command", cmd: "/clear", args: [] });
expect(parseInput("/Clear")).toEqual({ type: "command", cmd: "/clear", args: [] });
expect(parseInput("/rant 希望支持主题切换")).toEqual({
type: "command",
cmd: "/rant",
args: ["希望支持主题切换"],
});
});
it("unknown command", () => {
expect(parseInput("/foobar")).toEqual({ type: "unknown", cmd: "/foobar" });
});
});

describe("getCompletions", () => {
it("empty prefix returns all 16", () => {
expect(getCompletions("", fakeT).length).toBe(16);
});
it("/r prefix filters to /rant /rename /resume /rewind", () => {
const cmds = getCompletions("/r", fakeT).map((i) => i.cmd).sort();
expect(cmds).toEqual(["/rant", "/rename", "/resume", "/rewind"].sort());
});
it("exact match returns single entry with hint", () => {
const c = getCompletions("/clear", fakeT);
expect(c.length).toBe(1);
expect(c[0].cmd).toBe("/clear");
expect(c[0].hint.length).toBeGreaterThan(0);
});
it("no match returns empty", () => {
expect(getCompletions("/zzz", fakeT).length).toBe(0);
});
});

describe("hintText", () => {
it("returns translated hint when t available", () => {
const t: TranslateFn = (key) => (key === "cmd.clear.hint" ? "清空会话" : key);
expect(hintText("/clear", t)).toBe("清空会话");
});
it("falls back to the dict key when t is unavailable", () => {
const t: TranslateFn = (key) => key;
expect(hintText("/clear", t)).toBe("cmd.clear.hint");
});
it("returns empty string for unknown command", () => {
expect(hintText("/nope", fakeT)).toBe("");
});
});
68 changes: 68 additions & 0 deletions emrg/gui/renderer/src/lib/commands.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
import type { TranslateFn } from "./utils";

/**
* commands.ts — / 指令注册表与解析器(vanilla renderer/js/commands.js 迁移,Batch 1)。
* 纯逻辑、不绑 DOM:React 组件与测试均可直接 import。
* 契约与 vanilla 版完全一致(COMMANDS 16 条 / parseInput 三态 / getCompletions 前缀过滤)。
*/
export interface CommandMeta {
hint: string;
phase: number;
}

/** 指令注册表:cmd → { hint, phase }(hint 为 i18n 词典键,显示时经 t() 解析) */
export const COMMANDS: Record<string, CommandMeta> = {
"/clear": { hint: "cmd.clear.hint", phase: 1 },
"/compact": { hint: "cmd.compact.hint", phase: 1 },
"/version": { hint: "cmd.version.hint", phase: 1 },
"/help": { hint: "cmd.help.hint", phase: 1 },
"/image": { hint: "cmd.image.hint", phase: 1 },
"/delete": { hint: "cmd.delete.hint", phase: 2 },
"/rename": { hint: "cmd.rename.hint", phase: 2 },
"/resume": { hint: "cmd.resume.hint", phase: 2 },
"/rewind": { hint: "cmd.rewind.hint", phase: 2 },
"/sessions": { hint: "cmd.sessions.hint", phase: 2 },
"/open": { hint: "cmd.open.hint", phase: 4 },
"/model": { hint: "cmd.model.hint", phase: 3 },
"/memory": { hint: "cmd.memory.hint", phase: 3 },
"/skills": { hint: "cmd.skills.hint", phase: 3 },
"/rant": { hint: "cmd.rant.hint", phase: 4 },
"/trigger": { hint: "cmd.trigger.hint", phase: 4 },
};

export type ParseResult =
| { type: "message" }
| { type: "command"; cmd: string; args: string[] }
| { type: "unknown"; cmd: string };

/**
* 解析输入:/ 开头且匹配注册表 → { type:"command", cmd, args };
* / 开头未匹配 → { type:"unknown", cmd };否则 { type:"message" }。
*/
export function parseInput(text: unknown): ParseResult {
const t = String(text ?? "").trim();
if (!t.startsWith("/")) return { type: "message" };
const [cmd, ...args] = t.split(/\s+/);
const key = cmd.toLowerCase();
if (COMMANDS[key]) return { type: "command", cmd: key, args };
return { type: "unknown", cmd: key };
}

/** 解析 i18n hint:词典键 → 当前语言文案(t 由调用方注入,i18n 未就绪时回退键本身) */
export function hintText(cmd: string, t: TranslateFn): string {
const meta = COMMANDS[cmd];
if (!meta) return "";
try {
return t(meta.hint);
} catch {
return meta.hint;
}
}

/** / 前缀补全:返回匹配的指令(含 hint),供补全菜单过滤 */
export function getCompletions(prefix: string, t: TranslateFn): Array<{ cmd: string; hint: string; phase: number }> {
const p = String(prefix ?? "").toLowerCase();
return Object.entries(COMMANDS)
.filter(([cmd]) => cmd.startsWith(p))
.map(([cmd, meta]) => ({ cmd, hint: hintText(cmd, t), phase: meta.phase }));
}
66 changes: 66 additions & 0 deletions emrg/gui/renderer/src/lib/copywriting.test.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
import { describe, expect, it } from "vitest";
import { buildCopy, createCopywriting, toolPhrases } from "./copywriting";
import type { TranslateFn } from "./utils";

/** 最小词典 + 计数翻译函数:验证 key 透传与参数插值 */
function makeT(counts: Record<string, number>): TranslateFn {
const dict: Record<string, string> = {
"tool.bash.doing": "运行中",
"tool.bash.done": "已完成",
"tool.fallback.doing": "处理中",
"tool.fallback.done": "完成",
"tool.failText": "操作失败",
"copy.disconnected": "连接断开",
"copy.growthCount": "已进化 {n} 次",
"copy.aboutEvolution": "进化 {n} 次",
"copy.aboutEvolutionEmpty": "尚未进化",
};
return (key, params) => {
counts[key] = (counts[key] ?? 0) + 1;
let s = dict[key] ?? key;
if (params) {
s = s.replace(/\{(\w+)\}/g, (m, k) => (k in params ? String(params[k]) : m));
}
return s;
};
}

describe("toolPhrases", () => {
it("known tool → doing/done phrases", () => {
const t = makeT({});
expect(toolPhrases("bash", t)).toEqual({ doing: "运行中", done: "已完成" });
});
it("missing tool → fallback phrases", () => {
const t = makeT({});
expect(toolPhrases(null, t)).toEqual({ doing: "处理中", done: "完成" });
expect(toolPhrases("", t)).toEqual({ doing: "处理中", done: "完成" });
});
});

describe("buildCopy", () => {
it("builds the copy dict with interpolated functions", () => {
const t = makeT({});
const copy = buildCopy(t);
expect(copy.disconnected).toBe("连接断开");
expect(copy.growthCount(5)).toBe("已进化 5 次");
expect(copy.aboutEvolution(3)).toBe("进化 3 次");
expect(copy.aboutEvolution(0)).toBe("尚未进化");
});
});

describe("createCopywriting", () => {
it("exposes COPY, TOOL_FAIL_TEXT and refresh() rebuilds on locale switch", () => {
const zhT = makeT({});
const copy = createCopywriting(zhT);
expect(copy.COPY.disconnected).toBe("连接断开");
expect(copy.TOOL_FAIL_TEXT).toBe("操作失败");
expect(copy.toolPhrases("bash").done).toBe("已完成");

// locale 切换:注入不同 t 的实例 → refresh 重建(vanilla refresh() 语义)
const enT: TranslateFn = (key) => (key === "copy.disconnected" ? "Disconnected" : key);
const en = createCopywriting(enT);
expect(en.COPY.disconnected).toBe("Disconnected");
en.refresh();
expect(en.COPY.disconnected).toBe("Disconnected");
});
});
89 changes: 89 additions & 0 deletions emrg/gui/renderer/src/lib/copywriting.ts
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
import type { TranslateFn } from "./utils";

/**
* copywriting.ts — 去黑话文案映射(vanilla renderer/js/copywriting.js 迁移,Batch 1)。
* 纯逻辑:t 由调用方注入(i18n context),不依赖 window.EMRG_I18N。
* 契约与 vanilla 版一致:toolPhrases / buildCopy / refresh / TOOL_FAIL_TEXT。
*/
export interface ToolPhrases {
doing: string;
done: string;
}

export interface CopyDict {
disconnected: string;
reconnected: string;
sessionBusy: string;
sendFailed: string;
deleteConfirmTitle: string;
deleteConfirmBody: string;
noSessions: string;
aboutEvolution: (n: number) => string;
growthCount: (n: number) => string;
growthNote: string;
evolutionToastTitle: string;
evolutionToastMsg: (n: number) => string;
evolutionToastSee: string;
evolutionToastDismiss: string;
}

/** 工具名 → 进行中 / 完成 短语(渐进披露:默认折叠,点开展示原始输出) */
export function toolPhrases(name: string | undefined | null, t: TranslateFn): ToolPhrases {
const base = name || "fallback";
return {
doing: t(`tool.${base}.doing`),
done: t(`tool.${base}.done`),
};
}

/** 系统状态文案(locale 敏感:切换语言后 refresh 重建;含 #501 P3 成长卡/toast 键) */
export function buildCopy(t: TranslateFn): CopyDict {
return {
disconnected: t("copy.disconnected"),
reconnected: t("copy.reconnected"),
sessionBusy: t("copy.sessionBusy"),
sendFailed: t("copy.sendFailed"),
deleteConfirmTitle: t("copy.deleteConfirmTitle"),
deleteConfirmBody: t("copy.deleteConfirmBody"),
noSessions: t("copy.noSessions"),
aboutEvolution: (n) =>
n ? t("copy.aboutEvolution", { n }) : t("copy.aboutEvolutionEmpty"),
// WorkBuddy P3(#501):成长卡 + 进化 toast
growthCount: (n) => t("copy.growthCount", { n }),
growthNote: t("copy.growthNote"),
evolutionToastTitle: t("copy.evolutionToastTitle"),
evolutionToastMsg: (n) => t("copy.evolutionToastMsg", { n }),
evolutionToastSee: t("copy.evolutionToastSee"),
evolutionToastDismiss: t("copy.evolutionToastDismiss"),
};
}

/** 实例工厂:持有当前 COPY + failText,refresh() 重建(locale 切换时调用) */
export interface Copywriting {
toolPhrases: (name: string | undefined | null) => ToolPhrases;
COPY: CopyDict;
refresh: () => void;
/** 工具失败文案(字符串属性,chat 区直接赋值用) */
TOOL_FAIL_TEXT: string;
}

export function createCopywriting(t: TranslateFn): Copywriting {
let COPY = buildCopy(t);
let failText = t("tool.failText");

function refresh(): void {
COPY = buildCopy(t);
failText = t("tool.failText");
}

return {
toolPhrases: (name) => toolPhrases(name, t),
get COPY() {
return COPY;
},
refresh,
get TOOL_FAIL_TEXT() {
return failText;
},
};
}
Loading
Loading