fix(webui): stabilize streaming output and settings i18n
This commit is contained in:
@@ -34,6 +34,60 @@ const SETTINGS_NAV_KEYS = [
|
||||
"runtime",
|
||||
"advanced",
|
||||
];
|
||||
const LOCALIZED_SETTINGS_COPY_KEYS = [
|
||||
"settings.backToChat",
|
||||
"settings.sidebar.title",
|
||||
"settings.sidebar.ariaLabel",
|
||||
"settings.nav.overview",
|
||||
"settings.nav.appearance",
|
||||
"settings.nav.models",
|
||||
"settings.nav.providers",
|
||||
"settings.nav.apps",
|
||||
"settings.nav.runtime",
|
||||
"settings.nav.advanced",
|
||||
"settings.sections.interface",
|
||||
"settings.sections.localPreferences",
|
||||
"settings.sections.webSearch",
|
||||
"settings.sections.webBehavior",
|
||||
"settings.sections.webuiSafety",
|
||||
"settings.sections.capabilities",
|
||||
"settings.sections.apps",
|
||||
"settings.rows.theme",
|
||||
"settings.rows.language",
|
||||
"settings.rows.density",
|
||||
"settings.rows.activityMode",
|
||||
"settings.rows.codeWrap",
|
||||
"settings.rows.brandLogos",
|
||||
"settings.rows.currentModel",
|
||||
"settings.rows.localServiceAccess",
|
||||
"settings.rows.webuiDefaultAccess",
|
||||
"settings.rows.contextWindow",
|
||||
"settings.help.theme",
|
||||
"settings.help.language",
|
||||
"settings.help.density",
|
||||
"settings.help.activityMode",
|
||||
"settings.help.codeWrap",
|
||||
"settings.help.brandLogos",
|
||||
"settings.help.currentModel",
|
||||
"settings.help.localServiceAccess",
|
||||
"settings.help.webuiDefaultAccess",
|
||||
"settings.values.light",
|
||||
"settings.values.dark",
|
||||
"settings.values.comfortable",
|
||||
"settings.values.compact",
|
||||
"settings.values.expanded",
|
||||
"settings.values.enabled",
|
||||
"settings.values.disabled",
|
||||
"settings.values.defaultPermission",
|
||||
"settings.values.fullAccess",
|
||||
"settings.values.configured",
|
||||
"settings.values.notConfigured",
|
||||
"settings.status.loading",
|
||||
"settings.status.unsaved",
|
||||
"settings.status.upToDate",
|
||||
"settings.actions.save",
|
||||
"settings.actions.saving",
|
||||
];
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return !!value && typeof value === "object" && !Array.isArray(value);
|
||||
}
|
||||
@@ -190,6 +244,20 @@ describe("webui i18n", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("does not leak English settings chrome into localized locales", () => {
|
||||
const english = flattenResource(resources.en.common);
|
||||
|
||||
for (const [locale, resource] of Object.entries(resources)) {
|
||||
if (locale === "en") continue;
|
||||
const current = flattenResource(resource.common);
|
||||
const leaked = LOCALIZED_SETTINGS_COPY_KEYS.filter(
|
||||
(key) => current.get(key) === english.get(key),
|
||||
);
|
||||
|
||||
expect({ locale, leaked }).toEqual({ locale, leaked: [] });
|
||||
}
|
||||
});
|
||||
|
||||
it("keeps Simplified Chinese settings overview copy localized", () => {
|
||||
const settings = resources["zh-CN"].common.settings;
|
||||
|
||||
|
||||
@@ -194,7 +194,8 @@ describe("ThreadMessages", () => {
|
||||
|
||||
render(<ThreadMessages messages={messages} isStreaming />);
|
||||
|
||||
expect(screen.getByLabelText(/editing foo\.txt/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/edited foo\.txt/i)).toBeInTheDocument();
|
||||
expect(screen.queryByLabelText(/editing foo\.txt/i)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("folds final answer reasoning into the preceding activity timeline", () => {
|
||||
|
||||
@@ -658,7 +658,7 @@ describe("useNanobotStream", () => {
|
||||
}]);
|
||||
});
|
||||
|
||||
it("keeps interrupted pre-tool text inside activity before the final answer", async () => {
|
||||
it("keeps interrupted pre-tool text as assistant output before activity", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-stream-segments", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
@@ -692,9 +692,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "I created the files.",
|
||||
isStreaming: false,
|
||||
content: "I created the files.",
|
||||
});
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
role: "tool",
|
||||
@@ -739,9 +737,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "",
|
||||
reasoning: "I will inspect the project first.",
|
||||
isStreaming: false,
|
||||
content: "I will inspect the project first.",
|
||||
});
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
role: "tool",
|
||||
@@ -755,6 +751,51 @@ describe("useNanobotStream", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("splits live assistant output around tool hints without moving it into reasoning", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-live-segments", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-live-segments", {
|
||||
event: "delta",
|
||||
chat_id: "chat-live-segments",
|
||||
text: "Lint passed; now rendering the video.",
|
||||
});
|
||||
fake.emit("chat-live-segments", {
|
||||
event: "message",
|
||||
chat_id: "chat-live-segments",
|
||||
text: 'exec({"cmd":"hyperframes render"})',
|
||||
kind: "tool_hint",
|
||||
});
|
||||
fake.emit("chat-live-segments", {
|
||||
event: "delta",
|
||||
chat_id: "chat-live-segments",
|
||||
text: "Rendered successfully.",
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(3);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Lint passed; now rendering the video.",
|
||||
});
|
||||
expect(result.current.messages[0].reasoning).toBeUndefined();
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
role: "tool",
|
||||
kind: "trace",
|
||||
traces: ['exec({"cmd":"hyperframes render"})'],
|
||||
});
|
||||
expect(result.current.messages[2]).toMatchObject({
|
||||
role: "assistant",
|
||||
content: "Rendered successfully.",
|
||||
});
|
||||
expect(result.current.messages[2].reasoning).toBeUndefined();
|
||||
});
|
||||
|
||||
it("opens a new activity segment for reasoning after file edit activity", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-file-segments", EMPTY_MESSAGES), {
|
||||
@@ -967,7 +1008,7 @@ describe("useNanobotStream", () => {
|
||||
expect(result.current.messages[0].reasoningStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("attaches post-hoc reasoning to the same assistant turn above the answer", () => {
|
||||
it("starts a new Thought block when reasoning arrives after visible output", () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-r5", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
@@ -988,12 +1029,61 @@ describe("useNanobotStream", () => {
|
||||
fake.emit("chat-r5", { event: "reasoning_end", chat_id: "chat-r5" });
|
||||
});
|
||||
|
||||
expect(result.current.messages).toHaveLength(1);
|
||||
expect(result.current.messages).toHaveLength(2);
|
||||
expect(result.current.messages[0].content).toBe("hi~");
|
||||
expect(result.current.messages[0].reasoning).toBe(
|
||||
expect(result.current.messages[0].reasoning).toBeUndefined();
|
||||
expect(result.current.messages[1].content).toBe("");
|
||||
expect(result.current.messages[1].reasoning).toBe(
|
||||
"This reasoning arrived after the answer stream.",
|
||||
);
|
||||
expect(result.current.messages[0].reasoningStreaming).toBe(false);
|
||||
expect(result.current.messages[1].reasoningStreaming).toBe(false);
|
||||
});
|
||||
|
||||
it("keeps alternating reasoning and answer deltas in separate ordered blocks", async () => {
|
||||
const fake = fakeClient();
|
||||
const { result } = renderHook(() => useNanobotStream("chat-r5b", EMPTY_MESSAGES), {
|
||||
wrapper: wrap(fake.client),
|
||||
});
|
||||
|
||||
act(() => {
|
||||
fake.emit("chat-r5b", {
|
||||
event: "reasoning_delta",
|
||||
chat_id: "chat-r5b",
|
||||
text: "Plan first.",
|
||||
});
|
||||
fake.emit("chat-r5b", {
|
||||
event: "delta",
|
||||
chat_id: "chat-r5b",
|
||||
text: "Visible progress.",
|
||||
});
|
||||
fake.emit("chat-r5b", {
|
||||
event: "reasoning_delta",
|
||||
chat_id: "chat-r5b",
|
||||
text: "Think again.",
|
||||
});
|
||||
fake.emit("chat-r5b", {
|
||||
event: "delta",
|
||||
chat_id: "chat-r5b",
|
||||
text: "Final visible text.",
|
||||
});
|
||||
});
|
||||
|
||||
await flushStreamFrame();
|
||||
|
||||
expect(result.current.messages).toHaveLength(2);
|
||||
expect(result.current.messages[0]).toMatchObject({
|
||||
role: "assistant",
|
||||
reasoning: "Plan first.",
|
||||
content: "Visible progress.",
|
||||
});
|
||||
expect(result.current.messages[1]).toMatchObject({
|
||||
role: "assistant",
|
||||
reasoning: "Think again.",
|
||||
content: "Final visible text.",
|
||||
});
|
||||
expect(result.current.messages[1].activitySegmentId).not.toBe(
|
||||
result.current.messages[0].activitySegmentId,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not attach a new turn's reasoning across the latest user boundary", async () => {
|
||||
|
||||
Reference in New Issue
Block a user