Skip to content

Commit 533e7e9

Browse files
committed
feat(chat): AI 对话转笔记双入口——会话顶栏整段 / AI 消息级至该条 + 完整问答转写 + 目标组可选(REQ-240)
1 parent 871d708 commit 533e7e9

5 files changed

Lines changed: 377 additions & 1 deletion

File tree

‎app/src/components/ChatMessageList.tsx‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@ interface Props {
2222
onEditUser?: (message: ChatMessage) => void;
2323
/** 当前正在编辑的消息 id(高亮) */
2424
editingId?: number | null;
25+
/** v0.16.1:AI 消息「存为笔记」入口(父层开保存对话框——至该条的完整上文) */
26+
onSaveMessage?: (message: ChatMessage) => void;
2527
}
2628

2729
function fmtTime(unix: number): string {
@@ -41,7 +43,7 @@ export function parseUsage(usageJson: string | null): { tokens: number | null }
4143
}
4244
}
4345

44-
export default function ChatMessageList({ messages, streaming, onRegenerate, onEditUser, editingId }: Props) {
46+
export default function ChatMessageList({ messages, streaming, onRegenerate, onEditUser, editingId, onSaveMessage }: Props) {
4547
const endRef = useRef<HTMLDivElement | null>(null);
4648
useEffect(() => {
4749
endRef.current?.scrollIntoView({ behavior: "smooth", block: "end" });
@@ -98,6 +100,19 @@ export default function ChatMessageList({ messages, streaming, onRegenerate, onE
98100
<div style={{ background: "#f8fafc", border: "1px solid #e5e7eb", borderRadius: 8, padding: "8px 12px" }}>
99101
<ChatMessageMarkdown content={m.content} />
100102
</div>
103+
{/* v0.16.1:AI 回答整段存为笔记(至该条的完整对话上下文) */}
104+
{onSaveMessage && (m.status === "done" || m.status === "aborted") && (
105+
<div style={{ display: "flex", justifyContent: "flex-end", marginTop: 2 }}>
106+
<button
107+
data-testid={`save-message-${m.id}`}
108+
onClick={() => onSaveMessage(m)}
109+
style={{ fontSize: 11, cursor: "pointer", border: "none", background: "none", color: "#0d9488", padding: "1px 4px" }}
110+
title="把这段对话(含提问)另存为笔记"
111+
>
112+
📄 存为笔记
113+
</button>
114+
</div>
115+
)}
101116
</div>
102117
)}
103118
</div>
Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
// @vitest-environment jsdom
2+
/**
3+
* ChatSaveNoteDialog.test.tsx — 对话另存为笔记对话框(v0.16.1)。AAA 模式。
4+
*
5+
* @ai-context: 覆盖四契约——① 保存(可带目标组)→ create_note + 成功态;
6+
* ② 失败红字不关窗;③ 保存成功态"在笔记页打开";④ 取消关闭。
7+
*/
8+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
9+
import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react";
10+
11+
const { invokeMock } = vi.hoisted(() => ({ invokeMock: vi.fn() }));
12+
vi.mock("@tauri-apps/api/core", () => ({ invoke: invokeMock }));
13+
14+
import ChatSaveNoteDialog from "./ChatSaveNoteDialog";
15+
import type { NoteGroup } from "../types";
16+
17+
const groups: NoteGroup[] = [
18+
{ id: 3, name: "AI 学习", terrain: "container", kind: "topic", domainTag: null, source: "manual", seriesKey: null, routeReason: null, routeOverridden: 0, color: null, noteCount: 0, createdAt: 0, updatedAt: 0 },
19+
];
20+
21+
const content = "> **🧑 你**\n\n> 什么是梯度下降\n\n**🤖 AI**\n\n梯度下降是一种优化算法。";
22+
23+
function renderDialog(overrides: Partial<Parameters<typeof ChatSaveNoteDialog>[0]> = {}) {
24+
const calls = {
25+
onOpenNote: vi.fn(),
26+
onClose: vi.fn(),
27+
...overrides,
28+
};
29+
render(<ChatSaveNoteDialog initialTitle="梯度下降" content={content} groups={groups} {...calls} />);
30+
return calls;
31+
}
32+
33+
beforeEach(() => {
34+
invokeMock.mockReset();
35+
invokeMock.mockResolvedValue({ id: 99, title: "梯度下降" });
36+
});
37+
afterEach(() => cleanup());
38+
39+
describe("ChatSaveNoteDialog", () => {
40+
it("保存(不归组)→ create_note(source=manual) + 成功态", async () => {
41+
renderDialog();
42+
fireEvent.click(screen.getByTestId("chat-note-save"));
43+
await waitFor(() => {
44+
expect(invokeMock).toHaveBeenCalledWith("create_note", {
45+
new: { title: "梯度下降", content, source: "manual" },
46+
});
47+
});
48+
expect(screen.getByTestId("chat-note-saved").textContent).toContain("#99");
49+
});
50+
51+
it("选目标组 → group_id 直入 NewNote", async () => {
52+
renderDialog();
53+
fireEvent.change(screen.getByTestId("chat-note-group"), { target: { value: "3" } });
54+
fireEvent.click(screen.getByTestId("chat-note-save"));
55+
await waitFor(() => {
56+
expect(invokeMock).toHaveBeenCalledWith("create_note", {
57+
new: { title: "梯度下降", content, source: "manual", group_id: 3 },
58+
});
59+
});
60+
});
61+
62+
it("保存失败 → 红字提示且对话框不关", async () => {
63+
invokeMock.mockRejectedValue("数据库满");
64+
const calls = renderDialog();
65+
fireEvent.click(screen.getByTestId("chat-note-save"));
66+
await waitFor(() => expect(screen.getByTestId("chat-note-error").textContent).toContain("数据库满"));
67+
expect(calls.onClose).not.toHaveBeenCalled();
68+
});
69+
70+
it("成功态「在笔记页打开」→ onOpenNote(noteId)", async () => {
71+
const calls = renderDialog();
72+
fireEvent.click(screen.getByTestId("chat-note-save"));
73+
await waitFor(() => expect(screen.getByTestId("chat-note-saved")).toBeTruthy());
74+
fireEvent.click(screen.getByTestId("chat-note-open"));
75+
expect(calls.onOpenNote).toHaveBeenCalledWith(99);
76+
});
77+
78+
it("取消关闭", () => {
79+
const calls = renderDialog();
80+
fireEvent.click(screen.getByTestId("chat-note-cancel"));
81+
expect(calls.onClose).toHaveBeenCalled();
82+
});
83+
});
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
/**
2+
* ChatSaveNoteDialog — 对话「另存为笔记」对话框(v0.16.1 用户决定② 双入口)。
3+
*
4+
* @ai-context: 入口两处共用本对话框——会话顶栏(整段对话)与 AI 消息悬浮
5+
* (至该条的完整上文)。字段:标题(默认由调用方给定)+ 目标组
6+
* (可选下拉,默认不归组)→ create_note(source=manual;组 id 经
7+
* group_id 直入——NewNote 契约已支持)。成功后内嵌"在笔记页打开"
8+
* (onOpenNote 已由 ChatPage 透传)+ 关闭;失败红字不关窗(改后再试)。
9+
*/
10+
import { useState } from "react";
11+
import { invoke } from "@tauri-apps/api/core";
12+
import type { Note, NoteGroup } from "../types";
13+
14+
interface Props {
15+
/** 默认标题(会话标题或首条提问) */
16+
initialTitle: string;
17+
/** 转写后的 Markdown 正文(utils/chatTranscript.buildConversationMarkdown) */
18+
content: string;
19+
groups: NoteGroup[];
20+
/** 在笔记页打开(成功态按钮;ChatPage 透传) */
21+
onOpenNote: (noteId: number) => void;
22+
onClose: () => void;
23+
}
24+
25+
const BTN_BASE: React.CSSProperties = {
26+
fontSize: 12.5,
27+
cursor: "pointer",
28+
padding: "5px 14px",
29+
borderRadius: 6,
30+
border: "1px solid #d1d5db",
31+
background: "#fff",
32+
color: "#374151",
33+
};
34+
35+
export default function ChatSaveNoteDialog({ initialTitle, content, groups, onOpenNote, onClose }: Props) {
36+
const [title, setTitle] = useState(initialTitle || "AI 对话记录");
37+
const [groupId, setGroupId] = useState<number | null>(null);
38+
const [busy, setBusy] = useState(false);
39+
const [savedId, setSavedId] = useState<number | null>(null);
40+
const [status, setStatus] = useState("");
41+
42+
const save = async () => {
43+
if (busy || !title.trim()) return;
44+
setBusy(true);
45+
setStatus("");
46+
try {
47+
const note = await invoke<Note>("create_note", {
48+
new: {
49+
title: title.trim(),
50+
content,
51+
source: "manual",
52+
...(groupId != null ? { group_id: groupId } : {}),
53+
},
54+
});
55+
setSavedId(note.id);
56+
setStatus("");
57+
} catch (e) {
58+
setStatus(`保存失败: ${e}`);
59+
} finally {
60+
setBusy(false);
61+
}
62+
};
63+
64+
return (
65+
<>
66+
<div
67+
onClick={onClose}
68+
data-testid="chat-note-backdrop"
69+
style={{ position: "fixed", inset: 0, zIndex: 50, background: "rgba(0,0,0,0.18)" }}
70+
/>
71+
<div
72+
data-testid="chat-note-dialog"
73+
style={{
74+
position: "fixed", zIndex: 51, top: "50%", left: "50%",
75+
transform: "translate(-50%, -50%)",
76+
width: 380, background: "#fff", borderRadius: 10,
77+
border: "1px solid #e5e7eb", boxShadow: "0 12px 32px rgba(0,0,0,0.18)",
78+
padding: 14, fontSize: 12.5,
79+
}}
80+
>
81+
<div style={{ fontWeight: 700, fontSize: 14, color: "#111827", marginBottom: 10 }}>
82+
📄 另存为笔记
83+
</div>
84+
85+
{savedId != null ? (
86+
<>
87+
<div data-testid="chat-note-saved" style={{ color: "#047857", marginBottom: 10 }}>
88+
✓ 已保存为笔记 #{savedId}({title})
89+
</div>
90+
<div style={{ display: "flex", gap: 8 }}>
91+
<button
92+
data-testid="chat-note-open"
93+
style={{ ...BTN_BASE, background: "#0d9488", color: "#fff", border: "none", fontWeight: 600 }}
94+
onClick={() => onOpenNote(savedId)}
95+
>
96+
在笔记页打开 →
97+
</button>
98+
<button style={BTN_BASE} onClick={onClose}>关闭</button>
99+
</div>
100+
</>
101+
) : (
102+
<>
103+
<label style={{ display: "block", color: "#6b7280", marginBottom: 4 }}>笔记标题</label>
104+
<input
105+
data-testid="chat-note-title"
106+
value={title}
107+
onChange={(e) => setTitle(e.target.value)}
108+
style={{ width: "100%", padding: "6px 8px", fontSize: 13, border: "1px solid #e5e7eb", borderRadius: 6, boxSizing: "border-box", marginBottom: 8 }}
109+
/>
110+
<label style={{ display: "block", color: "#6b7280", marginBottom: 4 }}>目标组(可选)</label>
111+
<select
112+
data-testid="chat-note-group"
113+
value={groupId ?? ""}
114+
onChange={(e) => setGroupId(e.target.value ? Number(e.target.value) : null)}
115+
style={{ width: "100%", padding: "6px 8px", fontSize: 13, border: "1px solid #e5e7eb", borderRadius: 6, boxSizing: "border-box", marginBottom: 8, background: "#fff" }}
116+
>
117+
<option value="">不归组(全部笔记)</option>
118+
{groups.map((g) => (
119+
<option key={g.id} value={g.id}>
120+
{g.name}
121+
</option>
122+
))}
123+
</select>
124+
<div style={{ fontSize: 11.5, color: "#6b7280", marginBottom: 10 }}>
125+
共 {content.length} 字符 · 对话以提问引用 + AI 回答全文的完整形式保存
126+
</div>
127+
{status && (
128+
<div data-testid="chat-note-error" style={{ color: "#dc2626", marginBottom: 8 }}>{status}</div>
129+
)}
130+
<div style={{ display: "flex", gap: 8, justifyContent: "flex-end" }}>
131+
<button data-testid="chat-note-cancel" style={BTN_BASE} onClick={onClose} disabled={busy}>取消</button>
132+
<button
133+
data-testid="chat-note-save"
134+
style={{ ...BTN_BASE, background: "#0d9488", color: "#fff", border: "none", fontWeight: 600 }}
135+
onClick={() => void save()}
136+
disabled={busy || !title.trim()}
137+
>
138+
{busy ? "保存中…" : "保存"}
139+
</button>
140+
</div>
141+
</>
142+
)}
143+
</div>
144+
</>
145+
);
146+
}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
/**
2+
* chatTranscript.test.ts — 对话转笔记转写纯函数(v0.16.1)。AAA 模式。
3+
*
4+
* @ai-context: 锁三契约——① 用户=引用块 / AI=正文 / 轮次空行分隔(完整对话含问答);
5+
* ② upToId 截断(消息级入口=自首条至该条的完整上下文);
6+
* ③ failed 跳过、aborted 保留并标注、空内容跳过。
7+
*/
8+
import { describe, expect, it } from "vitest";
9+
import { buildConversationMarkdown } from "./chatTranscript";
10+
import type { ChatMessage } from "../types";
11+
12+
function msg(partial: Partial<ChatMessage> & { id: number; role: "user" | "assistant"; content: string }): ChatMessage {
13+
return {
14+
sessionId: 1,
15+
model: null,
16+
usageJson: null,
17+
status: "done",
18+
createdAt: 0,
19+
...partial,
20+
};
21+
}
22+
23+
describe("buildConversationMarkdown", () => {
24+
// createdAt=0 的本地时标签(测试不硬编码时区)
25+
const t0 = (() => {
26+
const d = new Date(0);
27+
return `${String(d.getHours()).padStart(2, "0")}:${String(d.getMinutes()).padStart(2, "0")}`;
28+
})();
29+
30+
it("完整对话含问答:用户引用块 + AI 正文 + 轮次空行分隔", () => {
31+
const out = buildConversationMarkdown([
32+
msg({ id: 1, role: "user", content: "什么是梯度下降" }),
33+
msg({ id: 2, role: "assistant", content: "梯度下降是一种优化算法。" }),
34+
]);
35+
expect(out).toContain(`> **🧑 你(${t0})**`);
36+
expect(out).toContain("> 什么是梯度下降");
37+
expect(out).toContain(`**🤖 AI(— · ${t0})**\n\n梯度下降是一种优化算法。`);
38+
// 用户/AI 之间有空行分隔
39+
expect(out).toContain("> 什么是梯度下降\n\n**");
40+
});
41+
42+
it("多行用户内容整体进入引用块(逐行 > 前缀)", () => {
43+
const out = buildConversationMarkdown([msg({ id: 1, role: "user", content: "第一行\n第二行" })]);
44+
expect(out).toContain("> 第一行\n> 第二行");
45+
});
46+
47+
it("upToId:截断到该条(含)——消息级入口的完整上文", () => {
48+
const out = buildConversationMarkdown([
49+
msg({ id: 1, role: "user", content: "问题甲" }),
50+
msg({ id: 2, role: "assistant", content: "回答甲" }),
51+
msg({ id: 3, role: "user", content: "问题乙" }),
52+
msg({ id: 4, role: "assistant", content: "回答乙" }),
53+
], 2);
54+
expect(out).toContain("问题甲");
55+
expect(out).toContain("回答甲");
56+
expect(out).not.toContain("问题乙");
57+
expect(out).not.toContain("回答乙");
58+
});
59+
60+
it("upToId 未命中(消息已删除/跨会话错位)→ 回退全量(防空笔记)", () => {
61+
const out = buildConversationMarkdown([
62+
msg({ id: 1, role: "user", content: "问题甲" }),
63+
msg({ id: 2, role: "assistant", content: "回答甲" }),
64+
], 999);
65+
expect(out).toContain("问题甲");
66+
expect(out).toContain("回答甲");
67+
});
68+
69+
it("failed 跳过;aborted 保留并标注;空内容跳过", () => {
70+
const out = buildConversationMarkdown([
71+
msg({ id: 1, role: "user", content: "问题" }),
72+
msg({ id: 2, role: "assistant", content: "部分内容", status: "aborted" }),
73+
msg({ id: 3, role: "assistant", content: "生成失败", status: "failed" }),
74+
msg({ id: 4, role: "assistant", content: " " }),
75+
]);
76+
expect(out).toContain("部分内容");
77+
expect(out).toContain("AI(已停止)");
78+
expect(out).not.toContain("生成失败");
79+
});
80+
81+
it("空消息列表 → 空串", () => {
82+
expect(buildConversationMarkdown([])).toBe("");
83+
});
84+
});

0 commit comments

Comments
 (0)