@morlay/ui-conversation-message-actions 0.0.11 → 0.0.12
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +0 -6
- package/dist/client.js +831 -0
- package/{lib → dist}/index.d.mts +0 -58
- package/dist/index.d.mts.map +1 -0
- package/{lib → dist}/index.mjs +2 -80
- package/dist/index.mjs.map +1 -0
- package/dist/invariant.d.mts +8 -0
- package/dist/invariant.d.mts.map +1 -0
- package/dist/invariant.mjs +10 -0
- package/dist/invariant.mjs.map +1 -0
- package/package.json +35 -26
- package/src/client/chat-node/MessageEditDialog.module.css +24 -0
- package/src/client/chat-node/MessageEditDialog.tsx +71 -0
- package/src/client/chat-node/MessageIconActions.module.css +86 -0
- package/src/client/chat-node/MessageIconActions.tsx +189 -0
- package/src/client/chat-node/MessageItem.module.css +290 -0
- package/src/client/chat-node/MessageItem.tsx +198 -0
- package/src/client/chat-node/message-chrome.ts +61 -0
- package/src/client/chat-node/register.ts +33 -0
- package/src/client/chat-node/use-calendar-day.ts +23 -0
- package/src/client/controller.ts +327 -0
- package/src/client/css-modules.d.ts +4 -0
- package/src/client/import-action.tsx +74 -0
- package/src/client/index.ts +50 -0
- package/src/index.ts +936 -0
- package/src/invariant.ts +13 -0
- package/src/shared.ts +167 -0
- package/src/types.ts +73 -0
- package/lib/client.js +0 -2243
- package/lib/client.js.map +0 -1
- package/lib/index.d.mts.map +0 -1
- package/lib/index.mjs.map +0 -1
- package/lib/invariant.d.mts +0 -15
- package/lib/invariant.d.mts.map +0 -1
- package/lib/invariant.mjs +0 -22
- package/lib/invariant.mjs.map +0 -1
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
// MessageItem: the user / admitted-steering chat node renderer.
|
|
2
|
+
// 新版 `dsh-client-ui-chat` 已内置全部 chat-node 渲染器;本项目仅**替换**
|
|
3
|
+
// `user`/`steering` 两个 key(keyed slot reuse 即替换),在消息动作行上提供
|
|
4
|
+
// edit / retry(新版无此能力)。其余 key 由新版内置渲染器处理。
|
|
5
|
+
|
|
6
|
+
import { memo, useState } from "react";
|
|
7
|
+
import type { ReactNode } from "react";
|
|
8
|
+
import type { InjectFace } from "@deepseek-ai/dsh-client-ui-slots";
|
|
9
|
+
import { Button, Modal } from "@deepseek-ai/dsh-client-ui-primitives";
|
|
10
|
+
import type { UserMessageNode } from "@deepseek-ai/dsh-client-ui-chat/client";
|
|
11
|
+
import { JsonBlock, MessageText } from "@deepseek-ai/dsh-client-ui-primitives";
|
|
12
|
+
import type { ChatNodeViewProps, ChatViewSlotProps } from "@deepseek-ai/dsh-client-ui-chat/client";
|
|
13
|
+
import type { RenderMessageImages } from "@deepseek-ai/dsh-client-ui-conversation/client";
|
|
14
|
+
import { MessageIconActions } from "./MessageIconActions.tsx";
|
|
15
|
+
import { MessageEditDialog } from "./MessageEditDialog.tsx";
|
|
16
|
+
import css from "./MessageItem.module.css";
|
|
17
|
+
import type { EditableMessageBlock } from "../../shared.ts";
|
|
18
|
+
import type { SessionEditorFace } from "../controller.ts";
|
|
19
|
+
|
|
20
|
+
type UserImage = Extract<UserMessageNode["content"][number], { type: "image" }>;
|
|
21
|
+
|
|
22
|
+
function contentParts(content: readonly unknown[]): {
|
|
23
|
+
text: string;
|
|
24
|
+
images: { attachment: UserImage["attachment"] }[];
|
|
25
|
+
rest: unknown[];
|
|
26
|
+
} {
|
|
27
|
+
const texts: string[] = [];
|
|
28
|
+
const images: { attachment: UserImage["attachment"] }[] = [];
|
|
29
|
+
const rest: unknown[] = [];
|
|
30
|
+
for (const block of content) {
|
|
31
|
+
const b = block as { type?: string; text?: string; attachment?: unknown };
|
|
32
|
+
if (b.type === "text" && typeof b.text === "string") texts.push(b.text);
|
|
33
|
+
else if (b.type === "image" && b.attachment !== undefined) {
|
|
34
|
+
images.push({ attachment: (b as UserImage).attachment });
|
|
35
|
+
} else rest.push(block);
|
|
36
|
+
}
|
|
37
|
+
return { text: texts.join(""), images, rest };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function projectUserText(text: string): ReactNode {
|
|
41
|
+
const re = /(^|\s)([/@][\w-]+)(?=\s|$)/g;
|
|
42
|
+
const parts: ReactNode[] = [];
|
|
43
|
+
let cursor = 0;
|
|
44
|
+
let m: RegExpExecArray | null;
|
|
45
|
+
while ((m = re.exec(text)) !== null) {
|
|
46
|
+
const tokenStart = m.index + (m[1]?.length ?? 0);
|
|
47
|
+
const label = m[2] ?? "";
|
|
48
|
+
if (tokenStart > cursor)
|
|
49
|
+
parts.push(<MessageText key={cursor} text={text.slice(cursor, tokenStart)} />);
|
|
50
|
+
parts.push(
|
|
51
|
+
<span
|
|
52
|
+
key={tokenStart}
|
|
53
|
+
className={css.refChip}
|
|
54
|
+
data-ref-chip={label.startsWith("@") ? "subagent" : "skill"}
|
|
55
|
+
>
|
|
56
|
+
{label}
|
|
57
|
+
</span>,
|
|
58
|
+
);
|
|
59
|
+
cursor = tokenStart + label.length;
|
|
60
|
+
}
|
|
61
|
+
if (parts.length === 0) return <MessageText text={text} />;
|
|
62
|
+
if (cursor < text.length) parts.push(<MessageText key={cursor} text={text.slice(cursor)} />);
|
|
63
|
+
return <>{parts}</>;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function UserStyleBubble({
|
|
67
|
+
content,
|
|
68
|
+
renderMessageImages,
|
|
69
|
+
actions,
|
|
70
|
+
t,
|
|
71
|
+
}: {
|
|
72
|
+
content: readonly unknown[];
|
|
73
|
+
renderMessageImages: RenderMessageImages;
|
|
74
|
+
|
|
75
|
+
actions?: (text: string) => ReactNode;
|
|
76
|
+
t: ChatViewSlotProps["t"];
|
|
77
|
+
}): ReactNode {
|
|
78
|
+
const { text, images, rest } = contentParts(content);
|
|
79
|
+
const truncated = (total: number): string => t("json.truncated", { total });
|
|
80
|
+
const showBubble = text !== "" || rest.length > 0;
|
|
81
|
+
return (
|
|
82
|
+
<div className={css.userRow} data-time-hover-root>
|
|
83
|
+
<div className={css.userStack}>
|
|
84
|
+
{renderMessageImages({ images, align: "end" })}
|
|
85
|
+
{showBubble && (
|
|
86
|
+
<div className={css.bubble}>
|
|
87
|
+
{projectUserText(text)}
|
|
88
|
+
{rest.map((block, i) => (
|
|
89
|
+
<JsonBlock
|
|
90
|
+
key={i}
|
|
91
|
+
label={t("message.extraBlock")}
|
|
92
|
+
payload={block}
|
|
93
|
+
truncatedLabel={truncated}
|
|
94
|
+
/>
|
|
95
|
+
))}
|
|
96
|
+
</div>
|
|
97
|
+
)}
|
|
98
|
+
</div>
|
|
99
|
+
{actions?.(text)}
|
|
100
|
+
</div>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export const UserMessageNodeView = memo(function UserMessageNodeView({
|
|
105
|
+
node,
|
|
106
|
+
renderMessageImages,
|
|
107
|
+
t,
|
|
108
|
+
edit,
|
|
109
|
+
retry,
|
|
110
|
+
}: ChatNodeViewProps<"user" | "steering"> & InjectFace<SessionEditorFace>) {
|
|
111
|
+
const data = node.data;
|
|
112
|
+
const [editing, setEditing] = useState<EditableMessageBlock | null>(null);
|
|
113
|
+
const [confirmingRetry, setConfirmingRetry] = useState(false);
|
|
114
|
+
const turnLocation =
|
|
115
|
+
node.location.kind === "turn" || node.location.kind === "step" ? node.location.turn : undefined;
|
|
116
|
+
const turn = turnLocation?.turn;
|
|
117
|
+
// 重试只对已闭合轮次开放(未闭合/无闭合边界的轮次服务端无法重放)。
|
|
118
|
+
const retryable = turnLocation?.status === "closed";
|
|
119
|
+
// 编辑目标:第一个文本块(与 Timeline 编辑面的 blockIndex 对齐)。
|
|
120
|
+
const textBlockIndex = data.content.findIndex(
|
|
121
|
+
(block) => (block as { type?: string }).type === "text",
|
|
122
|
+
);
|
|
123
|
+
const textBlock =
|
|
124
|
+
textBlockIndex === -1 ? undefined : (data.content[textBlockIndex] as { text?: string });
|
|
125
|
+
const onEdit =
|
|
126
|
+
textBlock === undefined || turn === undefined
|
|
127
|
+
? undefined
|
|
128
|
+
: () => {
|
|
129
|
+
setEditing({
|
|
130
|
+
key: `${node.anchorSeq}:${String(textBlockIndex)}`,
|
|
131
|
+
turn,
|
|
132
|
+
eventSeq: node.anchorSeq,
|
|
133
|
+
blockIndex: textBlockIndex,
|
|
134
|
+
kind: "user",
|
|
135
|
+
text: textBlock.text ?? "",
|
|
136
|
+
time: data.time,
|
|
137
|
+
});
|
|
138
|
+
};
|
|
139
|
+
// 重试先弹确认(就地编辑会抛弃该回合及其后的内容)。
|
|
140
|
+
const onRetry =
|
|
141
|
+
retryable && turn !== undefined
|
|
142
|
+
? () => {
|
|
143
|
+
setConfirmingRetry(true);
|
|
144
|
+
}
|
|
145
|
+
: undefined;
|
|
146
|
+
return (
|
|
147
|
+
<>
|
|
148
|
+
{editing !== null && (
|
|
149
|
+
<MessageEditDialog
|
|
150
|
+
block={editing}
|
|
151
|
+
onSave={(text) => edit(editing, text, "truncate")}
|
|
152
|
+
onClose={() => setEditing(null)}
|
|
153
|
+
/>
|
|
154
|
+
)}
|
|
155
|
+
{confirmingRetry && turn !== undefined && (
|
|
156
|
+
<Modal
|
|
157
|
+
open
|
|
158
|
+
onClose={() => setConfirmingRetry(false)}
|
|
159
|
+
title="重试回合"
|
|
160
|
+
closeLabel="关闭"
|
|
161
|
+
description={`将重新生成第 ${turn} 轮的回复,并抛弃该回合之后的内容。`}
|
|
162
|
+
footer={
|
|
163
|
+
<div className={css.confirmActions}>
|
|
164
|
+
<Button variant="outline" onClick={() => setConfirmingRetry(false)}>
|
|
165
|
+
取消
|
|
166
|
+
</Button>
|
|
167
|
+
<Button
|
|
168
|
+
variant="primary"
|
|
169
|
+
onClick={() => {
|
|
170
|
+
setConfirmingRetry(false);
|
|
171
|
+
void retry(turn, "truncate");
|
|
172
|
+
}}
|
|
173
|
+
>
|
|
174
|
+
确认重试
|
|
175
|
+
</Button>
|
|
176
|
+
</div>
|
|
177
|
+
}
|
|
178
|
+
/>
|
|
179
|
+
)}
|
|
180
|
+
<UserStyleBubble
|
|
181
|
+
content={data.content}
|
|
182
|
+
renderMessageImages={renderMessageImages}
|
|
183
|
+
t={t}
|
|
184
|
+
actions={(text) => (
|
|
185
|
+
<MessageIconActions
|
|
186
|
+
text={text}
|
|
187
|
+
time={data.time}
|
|
188
|
+
clock="start"
|
|
189
|
+
className={css.actions}
|
|
190
|
+
t={t}
|
|
191
|
+
onEdit={onEdit}
|
|
192
|
+
onRetry={onRetry}
|
|
193
|
+
/>
|
|
194
|
+
)}
|
|
195
|
+
/>
|
|
196
|
+
</>
|
|
197
|
+
);
|
|
198
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Shared time-label helpers for user/assistant IconActions rows.
|
|
2
|
+
|
|
3
|
+
import type { Translate } from "@deepseek-ai/dsh-client-ui-slots";
|
|
4
|
+
|
|
5
|
+
export type ClockTranslate = Translate<"clock.md" | "clock.ymd">;
|
|
6
|
+
|
|
7
|
+
export type RunDurationTranslate = Translate<"duration.seconds" | "duration.minutes">;
|
|
8
|
+
function pad2(n: number): string {
|
|
9
|
+
return String(n).padStart(2, "0");
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function startOfLocalDay(ms: number): number {
|
|
13
|
+
const d = new Date(ms);
|
|
14
|
+
d.setHours(0, 0, 0, 0);
|
|
15
|
+
return d.getTime();
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function msUntilNextLocalMidnight(ms: number): number {
|
|
19
|
+
const next = new Date(ms);
|
|
20
|
+
next.setHours(24, 0, 0, 0);
|
|
21
|
+
return Math.max(next.getTime() - ms, 1);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function formatRunDuration(ms: number, t: RunDurationTranslate): string {
|
|
25
|
+
const total = Math.max(0, Math.floor(ms / 1000));
|
|
26
|
+
const minutes = Math.floor(total / 60);
|
|
27
|
+
const seconds = total % 60;
|
|
28
|
+
return minutes > 0
|
|
29
|
+
? t("duration.minutes", { minutes, seconds: String(seconds).padStart(2, "0") })
|
|
30
|
+
: t("duration.seconds", { seconds });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function formatLatencySeconds(ms: number): string {
|
|
34
|
+
const s = Math.max(0, ms) / 1000;
|
|
35
|
+
return s < 10 ? String(Math.round(s * 10) / 10) : String(Math.round(s));
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function formatTokensPerSecond(tps: number): string {
|
|
39
|
+
const clamped = Math.max(0, tps);
|
|
40
|
+
return clamped >= 10 ? String(Math.round(clamped)) : String(Math.round(clamped * 10) / 10);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function formatMessageClock(
|
|
44
|
+
time: number,
|
|
45
|
+
t: ClockTranslate,
|
|
46
|
+
now: number = Date.now(),
|
|
47
|
+
): string {
|
|
48
|
+
const d = new Date(time);
|
|
49
|
+
const n = new Date(now);
|
|
50
|
+
const clock = `${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
51
|
+
if (
|
|
52
|
+
d.getFullYear() === n.getFullYear() &&
|
|
53
|
+
d.getMonth() === n.getMonth() &&
|
|
54
|
+
d.getDate() === n.getDate()
|
|
55
|
+
) {
|
|
56
|
+
return clock;
|
|
57
|
+
}
|
|
58
|
+
const params = { y: d.getFullYear(), m: d.getMonth() + 1, d: d.getDate() };
|
|
59
|
+
const md = d.getFullYear() === n.getFullYear() ? t("clock.md", params) : t("clock.ymd", params);
|
|
60
|
+
return `${md} ${clock}`;
|
|
61
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Context } from "@deepseek-ai/cordis";
|
|
2
|
+
import type { SessionId } from "@deepseek-ai/dsh-session";
|
|
3
|
+
import "@deepseek-ai/dsh-client-ui-conversation/client";
|
|
4
|
+
import "@deepseek-ai/dsh-client-ui-chat/client";
|
|
5
|
+
import type {} from "@deepseek-ai/dsh-client-ui-renderer/client";
|
|
6
|
+
import { UserMessageNodeView } from "./MessageItem.tsx";
|
|
7
|
+
import type { SessionEditorController } from "../controller.ts";
|
|
8
|
+
|
|
9
|
+
const NS = "conversation";
|
|
10
|
+
|
|
11
|
+
export function registerChatNodeRenderers(
|
|
12
|
+
ctx: Context,
|
|
13
|
+
controllerFor: (sessionId: SessionId) => SessionEditorController,
|
|
14
|
+
): void {
|
|
15
|
+
const injectFace = (sessionId: SessionId) => controllerFor(sessionId).face;
|
|
16
|
+
|
|
17
|
+
for (const key of ["user", "steering"] as const) {
|
|
18
|
+
ctx.slots.inject("conversation.chat.node", () =>
|
|
19
|
+
ctx.slots.register(
|
|
20
|
+
{
|
|
21
|
+
name: "conversation.chat.node",
|
|
22
|
+
key,
|
|
23
|
+
locale: NS,
|
|
24
|
+
// keyed slot 同 key 同 priority 会抛错;priority -1 让本项目
|
|
25
|
+
// 渲染器以最低优先级渲染,shadow 上游默认(priority 0)注册。
|
|
26
|
+
priority: -1,
|
|
27
|
+
inject: injectFace,
|
|
28
|
+
} as never,
|
|
29
|
+
UserMessageNodeView as never,
|
|
30
|
+
),
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
// Component-local calendar-day tick: memoized message rows keep stable props
|
|
2
|
+
// across midnight, so the IconActions clock needs a local day seat that
|
|
3
|
+
// re-fires at the next local midnight without reaching for framework hooks.
|
|
4
|
+
|
|
5
|
+
import { useEffect, useState } from "react";
|
|
6
|
+
import { msUntilNextLocalMidnight, startOfLocalDay } from "./message-chrome.ts";
|
|
7
|
+
|
|
8
|
+
export function useCalendarDay(): number {
|
|
9
|
+
const [day, setDay] = useState(() => startOfLocalDay(Date.now()));
|
|
10
|
+
useEffect(() => {
|
|
11
|
+
let timer: ReturnType<typeof setTimeout>;
|
|
12
|
+
const arm = (): void => {
|
|
13
|
+
const now = Date.now();
|
|
14
|
+
setDay(startOfLocalDay(now));
|
|
15
|
+
timer = setTimeout(arm, msUntilNextLocalMidnight(now));
|
|
16
|
+
};
|
|
17
|
+
timer = setTimeout(arm, msUntilNextLocalMidnight(Date.now()));
|
|
18
|
+
return () => {
|
|
19
|
+
clearTimeout(timer);
|
|
20
|
+
};
|
|
21
|
+
}, []);
|
|
22
|
+
return day;
|
|
23
|
+
}
|
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import type { Context as ClientContext } from "@deepseek-ai/cordis";
|
|
2
|
+
import type {
|
|
3
|
+
ISessions,
|
|
4
|
+
SessionFace,
|
|
5
|
+
SessionSnapshot,
|
|
6
|
+
} from "@deepseek-ai/dsh-api-session-controller/client";
|
|
7
|
+
import type { ObservableSnapshot, SnapshotStore } from "@deepseek-ai/dsh-client-store";
|
|
8
|
+
import { createSnapshotStore } from "@deepseek-ai/dsh-client-store";
|
|
9
|
+
import type { SessionId } from "@deepseek-ai/dsh-session";
|
|
10
|
+
import {
|
|
11
|
+
SESSION_EDITOR_PATH,
|
|
12
|
+
type EditableMessageBlock,
|
|
13
|
+
type SessionEditorOperation,
|
|
14
|
+
type SessionEditorOperationResult,
|
|
15
|
+
type SessionEditorTimeline,
|
|
16
|
+
type VersionOperation,
|
|
17
|
+
} from "../shared.ts";
|
|
18
|
+
|
|
19
|
+
export interface SessionEditorState {
|
|
20
|
+
status: "idle" | "loading" | "ready" | "error";
|
|
21
|
+
error: string | null;
|
|
22
|
+
pending: VersionOperation | "import" | null;
|
|
23
|
+
timeline: SessionEditorTimeline | null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface SessionEditorFace {
|
|
27
|
+
hooks: { sessionEditor: ObservableSnapshot<SessionEditorState> };
|
|
28
|
+
acquire(): () => void;
|
|
29
|
+
load(): void;
|
|
30
|
+
edit(
|
|
31
|
+
message: EditableMessageBlock,
|
|
32
|
+
text: string,
|
|
33
|
+
cascade: "truncate" | "preserve",
|
|
34
|
+
): Promise<boolean>;
|
|
35
|
+
retry(turn: number, cascade: "truncate" | "preserve"): Promise<boolean>;
|
|
36
|
+
reroll(): Promise<boolean>;
|
|
37
|
+
rewind(toBoundary: number): Promise<boolean>;
|
|
38
|
+
|
|
39
|
+
importSession(file: File): Promise<boolean>;
|
|
40
|
+
openVersion(sessionId: string): Promise<void>;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function messageOf(error: unknown): string {
|
|
44
|
+
return error instanceof Error ? error.message : String(error);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function conversationRevision(snapshot: SessionSnapshot): string {
|
|
48
|
+
// 新版 SessionSnapshot 已无 turnEnds(会话级事件窗口不再暴露轮次边界);
|
|
49
|
+
// 用生命周期字段作为会话变化指纹即可。
|
|
50
|
+
return [snapshot.openState, snapshot.removed, snapshot.hasMore].join("|");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export class SessionEditorController {
|
|
54
|
+
readonly store: SnapshotStore<SessionEditorState> = createSnapshotStore<SessionEditorState>({
|
|
55
|
+
status: "idle",
|
|
56
|
+
error: null,
|
|
57
|
+
pending: null,
|
|
58
|
+
timeline: null,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
readonly face: SessionEditorFace;
|
|
62
|
+
private readonly ctx: ClientContext;
|
|
63
|
+
private readonly sessions: ISessions;
|
|
64
|
+
private sessionSource: SessionFace | undefined;
|
|
65
|
+
private sessionSourceDispose: (() => void) | undefined;
|
|
66
|
+
private sessionRevision: string | undefined;
|
|
67
|
+
private disposed = false;
|
|
68
|
+
private users = 0;
|
|
69
|
+
private readonly navigationWaits = new Set<() => void>();
|
|
70
|
+
|
|
71
|
+
constructor(
|
|
72
|
+
ctx: ClientContext,
|
|
73
|
+
private readonly sessionId: SessionId,
|
|
74
|
+
) {
|
|
75
|
+
this.ctx = ctx;
|
|
76
|
+
this.sessions = ctx.get("sessions") as unknown as ISessions;
|
|
77
|
+
this.face = {
|
|
78
|
+
hooks: { sessionEditor: this.store },
|
|
79
|
+
acquire: () => {
|
|
80
|
+
this.users += 1;
|
|
81
|
+
if (this.users === 1 && this.disposed) this.revive();
|
|
82
|
+
return () => this.release();
|
|
83
|
+
},
|
|
84
|
+
load: () => {
|
|
85
|
+
void this.load();
|
|
86
|
+
},
|
|
87
|
+
edit: (message, text, cascade) =>
|
|
88
|
+
this.mutate({
|
|
89
|
+
action: "edit",
|
|
90
|
+
sessionId: this.sessionId,
|
|
91
|
+
eventSeq: message.eventSeq,
|
|
92
|
+
blockIndex: message.blockIndex,
|
|
93
|
+
text,
|
|
94
|
+
cascade,
|
|
95
|
+
}),
|
|
96
|
+
retry: (turn, cascade) =>
|
|
97
|
+
this.mutate({ action: "retry", sessionId: this.sessionId, turn, cascade }),
|
|
98
|
+
reroll: () => this.mutate({ action: "reroll", sessionId: this.sessionId }),
|
|
99
|
+
rewind: (toBoundary) =>
|
|
100
|
+
this.mutate({ action: "rewind", sessionId: this.sessionId, toBoundary }),
|
|
101
|
+
importSession: (file) => this.importSession(file),
|
|
102
|
+
openVersion: (sessionId) => this.openWhenListed(sessionId as SessionId),
|
|
103
|
+
};
|
|
104
|
+
this.observe();
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private observe(): void {
|
|
108
|
+
this.sessionSource = undefined;
|
|
109
|
+
this.sessionSourceDispose?.();
|
|
110
|
+
this.bindSessionSource();
|
|
111
|
+
this.sessions.list.subscribe(() => this.invalidate());
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
private bindSessionSource(): void {
|
|
115
|
+
const source = this.sessions.binding(this.sessionId)?.session;
|
|
116
|
+
if (source === this.sessionSource) return;
|
|
117
|
+
this.sessionSourceDispose?.();
|
|
118
|
+
this.sessionSource = source;
|
|
119
|
+
this.sessionRevision =
|
|
120
|
+
source === undefined ? undefined : conversationRevision(source.getSnapshot());
|
|
121
|
+
this.sessionSourceDispose = source?.subscribe(() => {
|
|
122
|
+
this.invalidate();
|
|
123
|
+
});
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
private invalidate(): void {
|
|
127
|
+
if (this.disposed || this.store.getSnapshot().status === "idle") return;
|
|
128
|
+
// 会话状态变化 → 刷新投影(debounce 由调用方按需)。
|
|
129
|
+
void this.load();
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
private release(): void {
|
|
133
|
+
this.users -= 1;
|
|
134
|
+
if (this.users <= 0) this.dispose();
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
private dispose(): void {
|
|
138
|
+
if (this.disposed) return;
|
|
139
|
+
this.disposed = true;
|
|
140
|
+
this.sessionSourceDispose?.();
|
|
141
|
+
this.sessionSourceDispose = undefined;
|
|
142
|
+
this.sessionSource = undefined;
|
|
143
|
+
this.sessionRevision = undefined;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
private revive(): void {
|
|
147
|
+
this.disposed = false;
|
|
148
|
+
this.observe();
|
|
149
|
+
void this.load();
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
async load(): Promise<void> {
|
|
153
|
+
if (this.disposed) return;
|
|
154
|
+
this.store.update((state) => {
|
|
155
|
+
state.status = "loading";
|
|
156
|
+
state.error = null;
|
|
157
|
+
});
|
|
158
|
+
try {
|
|
159
|
+
const response = await fetch(
|
|
160
|
+
`${SESSION_EDITOR_PATH}?sessionId=${encodeURIComponent(this.sessionId)}`,
|
|
161
|
+
{
|
|
162
|
+
method: "GET",
|
|
163
|
+
headers: { accept: "application/json" },
|
|
164
|
+
cache: "no-store",
|
|
165
|
+
},
|
|
166
|
+
);
|
|
167
|
+
const value = (await response.json()) as unknown;
|
|
168
|
+
if (this.disposed) return;
|
|
169
|
+
if (response.ok) {
|
|
170
|
+
this.store.update((state) => {
|
|
171
|
+
state.status = "ready";
|
|
172
|
+
state.error = null;
|
|
173
|
+
state.timeline = value as SessionEditorTimeline;
|
|
174
|
+
});
|
|
175
|
+
} else {
|
|
176
|
+
const error = (value as { error?: unknown })["error"];
|
|
177
|
+
this.store.update((state) => {
|
|
178
|
+
state.status = "error";
|
|
179
|
+
state.error =
|
|
180
|
+
typeof error === "string" ? error : `请求失败:HTTP ${String(response.status)}`;
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
} catch (error) {
|
|
184
|
+
if (this.disposed) return;
|
|
185
|
+
this.store.update((state) => {
|
|
186
|
+
state.status = "error";
|
|
187
|
+
state.error = messageOf(error);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
private async mutate(operation: SessionEditorOperation): Promise<boolean> {
|
|
193
|
+
// 只拦截并发操作;不要求 status === "ready"——编辑/重试的数据来自客户端
|
|
194
|
+
// conversation 节点,与 timeline 加载无关,status 为 idle 时也可发起请求。
|
|
195
|
+
const current = this.store.getSnapshot();
|
|
196
|
+
if (current.pending !== null) return false;
|
|
197
|
+
this.store.update((state) => {
|
|
198
|
+
state.pending = operation.action;
|
|
199
|
+
state.error = null;
|
|
200
|
+
});
|
|
201
|
+
try {
|
|
202
|
+
const response = await fetch(SESSION_EDITOR_PATH, {
|
|
203
|
+
method: "POST",
|
|
204
|
+
headers: { accept: "application/json", "content-type": "application/json" },
|
|
205
|
+
body: JSON.stringify(operation),
|
|
206
|
+
});
|
|
207
|
+
const value = (await response.json()) as unknown;
|
|
208
|
+
if (this.disposed) return true;
|
|
209
|
+
if (!response.ok) {
|
|
210
|
+
const error = (value as { error?: unknown })["error"];
|
|
211
|
+
throw new Error(
|
|
212
|
+
typeof error === "string" ? error : `请求失败:HTTP ${String(response.status)}`,
|
|
213
|
+
);
|
|
214
|
+
}
|
|
215
|
+
this.store.update((state) => {
|
|
216
|
+
state.pending = null;
|
|
217
|
+
});
|
|
218
|
+
const result = value as SessionEditorOperationResult;
|
|
219
|
+
// 仅 fork 产生新 id 时需要等列表发布并打开新版本;就地编辑不改 id。
|
|
220
|
+
if (String(result.sessionId) !== String(this.sessionId)) {
|
|
221
|
+
await this.openWhenListed(result.sessionId as SessionId);
|
|
222
|
+
return true;
|
|
223
|
+
}
|
|
224
|
+
// 就地编辑:rewind 删除事件,append-only 事件流无法表达删除——seq
|
|
225
|
+
// 回退只做增量,被剪掉的旧节点残留。优先会话级刷新(resync 重置窗口
|
|
226
|
+
// 并重新拉取历史);不可用时回退整页重载。
|
|
227
|
+
const face = this.sessions.binding(this.sessionId)?.session;
|
|
228
|
+
const resync = (face as unknown as { resync?: () => Promise<void> }).resync;
|
|
229
|
+
if (resync !== undefined) {
|
|
230
|
+
try {
|
|
231
|
+
await resync.call(face);
|
|
232
|
+
void this.load();
|
|
233
|
+
return true;
|
|
234
|
+
} catch {
|
|
235
|
+
// fall through to full reload
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
location.reload();
|
|
239
|
+
return true;
|
|
240
|
+
} catch (error) {
|
|
241
|
+
if (this.disposed) return false;
|
|
242
|
+
this.store.update((state) => {
|
|
243
|
+
state.pending = null;
|
|
244
|
+
state.error = messageOf(error);
|
|
245
|
+
});
|
|
246
|
+
return false;
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
private async importSession(file: File): Promise<boolean> {
|
|
251
|
+
const current = this.store.getSnapshot();
|
|
252
|
+
if (current.pending !== null) return false;
|
|
253
|
+
this.store.update((state) => {
|
|
254
|
+
state.pending = "import";
|
|
255
|
+
state.error = null;
|
|
256
|
+
});
|
|
257
|
+
try {
|
|
258
|
+
const zip = await new Promise<string>((resolve, reject) => {
|
|
259
|
+
const reader = new FileReader();
|
|
260
|
+
reader.onload = () => {
|
|
261
|
+
const dataUrl = typeof reader.result === "string" ? reader.result : "";
|
|
262
|
+
const comma = dataUrl.indexOf(",");
|
|
263
|
+
resolve(comma < 0 ? dataUrl : dataUrl.slice(comma + 1));
|
|
264
|
+
};
|
|
265
|
+
reader.onerror = () =>
|
|
266
|
+
reject(reader.error ?? new Error("failed to read the selected file"));
|
|
267
|
+
reader.readAsDataURL(file);
|
|
268
|
+
});
|
|
269
|
+
const response = await fetch("/api/session.import", {
|
|
270
|
+
method: "POST",
|
|
271
|
+
headers: { accept: "application/json", "content-type": "application/json" },
|
|
272
|
+
body: JSON.stringify({ zip, sessionId: this.sessionId }),
|
|
273
|
+
});
|
|
274
|
+
const value = (await response.json()) as unknown;
|
|
275
|
+
if (this.disposed) return true;
|
|
276
|
+
if (!response.ok) {
|
|
277
|
+
const error = (value as { error?: unknown })["error"];
|
|
278
|
+
throw new Error(
|
|
279
|
+
typeof error === "string" ? error : `请求失败:HTTP ${String(response.status)}`,
|
|
280
|
+
);
|
|
281
|
+
}
|
|
282
|
+
this.store.update((state) => {
|
|
283
|
+
state.pending = null;
|
|
284
|
+
});
|
|
285
|
+
// 覆盖成功但**不能**走 resync:resync 重开事件流时 observeSession 仍
|
|
286
|
+
// 优先读 live session(rewind 已截断其内存 log,append 只写 DB),会
|
|
287
|
+
// 再读到空/截断数据。整页重载让会话从 live 卸载,冷读 DB 完整数据。
|
|
288
|
+
location.reload();
|
|
289
|
+
return true;
|
|
290
|
+
} catch (error) {
|
|
291
|
+
if (this.disposed) return false;
|
|
292
|
+
this.store.update((state) => {
|
|
293
|
+
state.pending = null;
|
|
294
|
+
state.error = messageOf(error);
|
|
295
|
+
});
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
private openWhenListed(sessionId: SessionId): Promise<void> {
|
|
301
|
+
if (this.sessions.list.getSnapshot().byId[sessionId] !== undefined) {
|
|
302
|
+
this.sessions.open(sessionId);
|
|
303
|
+
return Promise.resolve();
|
|
304
|
+
}
|
|
305
|
+
return new Promise((resolve) => {
|
|
306
|
+
let settled = false;
|
|
307
|
+
let dispose = (): void => {};
|
|
308
|
+
const finish = (open: boolean): void => {
|
|
309
|
+
if (settled) return;
|
|
310
|
+
settled = true;
|
|
311
|
+
dispose();
|
|
312
|
+
this.navigationWaits.delete(cancel);
|
|
313
|
+
if (open) this.sessions.open(sessionId);
|
|
314
|
+
resolve();
|
|
315
|
+
};
|
|
316
|
+
const cancel = (): void => {
|
|
317
|
+
finish(false);
|
|
318
|
+
};
|
|
319
|
+
this.navigationWaits.add(cancel);
|
|
320
|
+
dispose = this.sessions.list.subscribe(() => {
|
|
321
|
+
if (this.sessions.list.getSnapshot().byId[sessionId] === undefined) return;
|
|
322
|
+
finish(true);
|
|
323
|
+
});
|
|
324
|
+
if (this.sessions.list.getSnapshot().byId[sessionId] !== undefined) finish(true);
|
|
325
|
+
});
|
|
326
|
+
}
|
|
327
|
+
}
|