@morlay/ui-conversation-message-actions 0.0.18 → 0.0.19-alpha.1
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 +29 -98
- package/dist/client.cjs +1380 -0
- package/dist/client.d.cts +6 -0
- package/dist/client.d.mts +6 -0
- package/dist/index.d.cts +75 -0
- package/dist/index.d.mts +3 -33
- package/dist/index.mjs +460 -2
- package/dist/invariant.d.cts +7 -0
- package/dist/{plan-BSa7k9HO.d.mts → plan-CT_6Y1fD.d.cts} +11 -9
- package/dist/plan-Cm9hCord.d.mts +101 -0
- package/dist/plan.d.cts +2 -0
- package/dist/plan.d.mts +2 -2
- package/dist/plan.mjs +145 -11
- package/dist/shared-LK3nImWW.mjs +55 -0
- package/dist/testing.d.cts +3242 -0
- package/dist/testing.d.mts +11 -5
- package/dist/testing.mjs +1976 -3775
- package/package.json +34 -31
- package/src/client/chat-node/MessageIconActions.styles.ts +76 -0
- package/src/client/chat-node/MessageIconActions.tsx +30 -11
- package/src/client/chat-node/MessageItem.styles.ts +247 -0
- package/src/client/chat-node/MessageItem.tsx +32 -39
- package/src/client/chat-node/message-chrome.ts +0 -2
- package/src/client/chat-node/register.ts +3 -4
- package/src/client/chat-node/use-calendar-day.ts +0 -4
- package/src/client/composer-stats/StatsPills.styles.ts +57 -0
- package/src/client/composer-stats/StatsPills.tsx +384 -0
- package/src/client/composer-stats/register.ts +17 -0
- package/src/client/composer-stats/stat-dialog.styles.ts +70 -0
- package/src/client/composer-stats/stat-dialog.ts +52 -0
- package/src/client/composer-stats/token-format.ts +85 -0
- package/src/client/composer-stats/turn-reading.ts +35 -0
- package/src/client/controller.ts +32 -114
- package/src/client/index.ts +8 -24
- package/src/index.ts +105 -118
- package/src/plan.ts +189 -24
- package/src/shared.ts +1 -1
- package/src/testing.ts +1 -5
- package/src/types.ts +1 -1
- package/dist/client.js +0 -806
- package/dist/src-B4HwXsob.mjs +0 -499
- package/src/client/chat-node/MessageIconActions.module.css +0 -86
- package/src/client/chat-node/MessageItem.module.css +0 -290
- package/src/client/css-modules.d.ts +0 -4
- package/src/client/import-action.tsx +0 -74
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import type { CSSProps } from "@morlay/dsh-client-ui-primitives/client";
|
|
2
|
+
|
|
3
|
+
export const styles = {
|
|
4
|
+
panel: {
|
|
5
|
+
position: "fixed",
|
|
6
|
+
zIndex: "1100",
|
|
7
|
+
boxSizing: "border-box",
|
|
8
|
+
width: "max-content",
|
|
9
|
+
minWidth: "min(300px, calc(100vw - 24px))",
|
|
10
|
+
maxWidth: "min(440px, calc(100vw - 24px))",
|
|
11
|
+
padding: "16px",
|
|
12
|
+
border: "0",
|
|
13
|
+
borderRadius: "12px",
|
|
14
|
+
background: "var(--dsw-specific-menu)",
|
|
15
|
+
"--dsw-elevation-stroke-color": "var(--dsw-alias-border-l1)",
|
|
16
|
+
boxShadow: "var(--dsw-elevation-prominent)",
|
|
17
|
+
fontSize: "12px",
|
|
18
|
+
lineHeight: "18px",
|
|
19
|
+
color: "var(--dsw-alias-label-secondary)",
|
|
20
|
+
cursor: "default",
|
|
21
|
+
},
|
|
22
|
+
title: {
|
|
23
|
+
display: "flex",
|
|
24
|
+
justifyContent: "space-between",
|
|
25
|
+
gap: "16px",
|
|
26
|
+
marginBottom: "8px",
|
|
27
|
+
color: "var(--dsw-alias-label-primary)",
|
|
28
|
+
fontWeight: "500",
|
|
29
|
+
},
|
|
30
|
+
titleRule: {
|
|
31
|
+
marginBottom: "10px",
|
|
32
|
+
borderTop: "0.5px solid var(--dsw-alias-border-l2)",
|
|
33
|
+
},
|
|
34
|
+
titleValue: {
|
|
35
|
+
fontVariantNumeric: "tabular-nums",
|
|
36
|
+
},
|
|
37
|
+
titleLabel: {
|
|
38
|
+
display: "inline-flex",
|
|
39
|
+
alignItems: "center",
|
|
40
|
+
gap: "6px",
|
|
41
|
+
minWidth: "0",
|
|
42
|
+
"& svg": {
|
|
43
|
+
width: "14px",
|
|
44
|
+
height: "14px",
|
|
45
|
+
flex: "none",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
details: {
|
|
49
|
+
display: "grid",
|
|
50
|
+
gridTemplateColumns: "minmax(76px, auto) minmax(0, 1fr)",
|
|
51
|
+
gap: "6px 16px",
|
|
52
|
+
margin: "0",
|
|
53
|
+
color: "var(--dsw-alias-label-tertiary)",
|
|
54
|
+
"& dt": {
|
|
55
|
+
minWidth: "0",
|
|
56
|
+
margin: "0",
|
|
57
|
+
},
|
|
58
|
+
"& dd": {
|
|
59
|
+
minWidth: "0",
|
|
60
|
+
margin: "0",
|
|
61
|
+
color: "var(--dsw-alias-label-secondary)",
|
|
62
|
+
fontVariantNumeric: "tabular-nums",
|
|
63
|
+
textAlign: "right",
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
reasoning: {
|
|
67
|
+
color: "var(--dsw-alias-label-tertiary)",
|
|
68
|
+
whiteSpace: "nowrap",
|
|
69
|
+
},
|
|
70
|
+
} satisfies Record<string, CSSProps>;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type CSSProperties, type MutableRefObject } from "react";
|
|
2
|
+
import {
|
|
3
|
+
useAnchoredPosition,
|
|
4
|
+
useDismissOnOutsidePointer,
|
|
5
|
+
} from "@deepseek-ai/dsh-client-ui-primitives";
|
|
6
|
+
|
|
7
|
+
const PANEL_MARGIN = 12;
|
|
8
|
+
|
|
9
|
+
const PANEL_GAP = 8;
|
|
10
|
+
|
|
11
|
+
export const MEASURE_STYLE: CSSProperties = { visibility: "hidden", left: 0, top: 0 };
|
|
12
|
+
|
|
13
|
+
export interface StatDialogSeat {
|
|
14
|
+
open: boolean;
|
|
15
|
+
setOpen: (open: boolean) => void;
|
|
16
|
+
rootRef: MutableRefObject<HTMLSpanElement | null>;
|
|
17
|
+
panelRef: MutableRefObject<HTMLDivElement | null>;
|
|
18
|
+
pos: CSSProperties | null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function useStatDialog(
|
|
22
|
+
controlled?: Pick<StatDialogSeat, "open" | "setOpen">,
|
|
23
|
+
): StatDialogSeat {
|
|
24
|
+
const [ownOpen, setOwnOpen] = useState(false);
|
|
25
|
+
const open = controlled?.open ?? ownOpen;
|
|
26
|
+
const setOpen = controlled?.setOpen ?? setOwnOpen;
|
|
27
|
+
const rootRef = useRef<HTMLSpanElement | null>(null);
|
|
28
|
+
const panelRef = useRef<HTMLDivElement | null>(null);
|
|
29
|
+
|
|
30
|
+
const pos = useAnchoredPosition({
|
|
31
|
+
open,
|
|
32
|
+
anchorRef: rootRef,
|
|
33
|
+
panelRef,
|
|
34
|
+
side: "top",
|
|
35
|
+
gap: PANEL_GAP,
|
|
36
|
+
margin: PANEL_MARGIN,
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
useDismissOnOutsidePointer(rootRef, open, setOpen, panelRef);
|
|
40
|
+
useEffect(() => {
|
|
41
|
+
if (!open) return;
|
|
42
|
+
const onKeyDown = (e: KeyboardEvent): void => {
|
|
43
|
+
if (e.key === "Escape") setOpen(false);
|
|
44
|
+
};
|
|
45
|
+
document.addEventListener("keydown", onKeyDown);
|
|
46
|
+
return () => {
|
|
47
|
+
document.removeEventListener("keydown", onKeyDown);
|
|
48
|
+
};
|
|
49
|
+
}, [open, setOpen]);
|
|
50
|
+
|
|
51
|
+
return { open, setOpen, rootRef, panelRef, pos };
|
|
52
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// Token 展示口径,随 composer 统计行从上游 ui-chat 搬来:`formatCacheHitPercent`
|
|
2
|
+
// 的越界分支是**主动修的缺陷**(上游只在未命中侧为 0 时返回 "100",
|
|
3
|
+
// cacheRead 超出 prompt 时展开循环的缩放间隙恒为负 → 同步死循环冻结主线程)。
|
|
4
|
+
import type { ChatViewSlotProps } from "@deepseek-ai/dsh-client-ui-chat/client";
|
|
5
|
+
|
|
6
|
+
export function formatTokens(value: number, t: ChatViewSlotProps["t"]): string {
|
|
7
|
+
const scaled = (candidate: number): string =>
|
|
8
|
+
candidate >= 100 ? String(Math.round(candidate)) : String(Math.round(candidate * 10) / 10);
|
|
9
|
+
if (value < 1_000) return String(value);
|
|
10
|
+
if (value < 1_000_000) return t("number.thousand", { value: scaled(value / 1_000) });
|
|
11
|
+
return t("number.million", { value: scaled(value / 1_000_000) });
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function formatExactTokens(value: number, t: ChatViewSlotProps["t"]): string {
|
|
15
|
+
const digits = String(value);
|
|
16
|
+
const groups: string[] = [];
|
|
17
|
+
for (let end = digits.length; end > 0; end -= 3) {
|
|
18
|
+
groups.unshift(digits.slice(Math.max(0, end - 3), end));
|
|
19
|
+
}
|
|
20
|
+
return groups.join(t("number.groupSeparator"));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function roundedPercentUnits(
|
|
24
|
+
cacheReadTokens: number,
|
|
25
|
+
denominator: number,
|
|
26
|
+
decimalPlaces: 0 | 1,
|
|
27
|
+
): number {
|
|
28
|
+
const unitsPerPercent = decimalPlaces === 0 ? 1 : 10;
|
|
29
|
+
const scale = unitsPerPercent * 100;
|
|
30
|
+
const doubledScale = scale * 2;
|
|
31
|
+
const denominatorQuotient = Math.floor(denominator / doubledScale);
|
|
32
|
+
const denominatorRemainder = denominator % doubledScale;
|
|
33
|
+
let lower = 0;
|
|
34
|
+
let upper = scale;
|
|
35
|
+
while (lower < upper) {
|
|
36
|
+
const candidate = Math.floor((lower + upper + 1) / 2);
|
|
37
|
+
const factor = candidate * 2 - 1;
|
|
38
|
+
const threshold =
|
|
39
|
+
factor * denominatorQuotient + Math.ceil((factor * denominatorRemainder) / doubledScale);
|
|
40
|
+
if (cacheReadTokens >= threshold) lower = candidate;
|
|
41
|
+
else upper = candidate - 1;
|
|
42
|
+
}
|
|
43
|
+
return lower;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function displayPercentUnits(units: number, decimalPlaces: 0 | 1): string {
|
|
47
|
+
if (decimalPlaces === 0) return String(units);
|
|
48
|
+
const whole = Math.floor(units / 10);
|
|
49
|
+
const tenths = units % 10;
|
|
50
|
+
return tenths === 0 ? String(whole) : `${whole}.${tenths}`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function formatCacheHitPercent(
|
|
54
|
+
cacheReadTokens: number,
|
|
55
|
+
promptTokens: number,
|
|
56
|
+
decimalPlaces: 0 | 1 = 0,
|
|
57
|
+
): string | null {
|
|
58
|
+
if (promptTokens === 0) return null;
|
|
59
|
+
const missedInputTokens = promptTokens - cacheReadTokens;
|
|
60
|
+
// 未命中侧为 0,或为负(cacheRead 超出 prompt 的越界输入)时都按全命中返回。
|
|
61
|
+
if (missedInputTokens <= 0) return "100";
|
|
62
|
+
|
|
63
|
+
const roundedUnits = roundedPercentUnits(cacheReadTokens, promptTokens, decimalPlaces);
|
|
64
|
+
const fullHitUnits = decimalPlaces === 0 ? 100 : 1_000;
|
|
65
|
+
if (roundedUnits < fullHitUnits) return displayPercentUnits(roundedUnits, decimalPlaces);
|
|
66
|
+
|
|
67
|
+
let distinguishingPlaces = 1;
|
|
68
|
+
let scaledDoubleGap = missedInputTokens * 200;
|
|
69
|
+
const denominatorTens = Math.floor(promptTokens / 10);
|
|
70
|
+
while (scaledDoubleGap <= denominatorTens) {
|
|
71
|
+
scaledDoubleGap *= 10;
|
|
72
|
+
distinguishingPlaces += 1;
|
|
73
|
+
}
|
|
74
|
+
const denominatorOnes = promptTokens % 10;
|
|
75
|
+
let roundedLoss = 5;
|
|
76
|
+
for (let loss = 1; loss < 5; loss += 1) {
|
|
77
|
+
const factor = loss * 2 + 1;
|
|
78
|
+
const threshold = factor * denominatorTens + Math.floor((factor * denominatorOnes) / 10);
|
|
79
|
+
if (scaledDoubleGap <= threshold) {
|
|
80
|
+
roundedLoss = loss;
|
|
81
|
+
break;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return `99.${"9".repeat(distinguishingPlaces - 1)}${10 - roundedLoss}`;
|
|
85
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
// 窗口折叠(无 `sessionStats` 投影时的兜底)读一条 assistant 步的时序 / 用量。
|
|
2
|
+
// 从上游 ui-chat 的 `contract/turn-metrics.ts` 取出统计行真正用到的那一半,
|
|
3
|
+
// 只服务 composer 统计行。
|
|
4
|
+
import type { AssistantMessageNode } from "@deepseek-ai/dsh-client-ui-chat/client";
|
|
5
|
+
|
|
6
|
+
export interface StepReading {
|
|
7
|
+
ttftMs: number | null;
|
|
8
|
+
|
|
9
|
+
decodeMs: number | null;
|
|
10
|
+
|
|
11
|
+
outputTokens: number | null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
interface UsageLike {
|
|
15
|
+
outputTokens?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function usageOutputTokens(usage: unknown): number | null {
|
|
19
|
+
if (typeof usage !== "object" || usage === null) return null;
|
|
20
|
+
const value = (usage as UsageLike).outputTokens;
|
|
21
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0 ? value : null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function assistantStepReading(node: AssistantMessageNode): StepReading {
|
|
25
|
+
const timing = node.timing;
|
|
26
|
+
const ttftMs =
|
|
27
|
+
timing !== undefined && timing.stepStartTime !== null && timing.firstTokenTime !== null
|
|
28
|
+
? Math.max(0, timing.firstTokenTime - timing.stepStartTime)
|
|
29
|
+
: null;
|
|
30
|
+
const decodeMs =
|
|
31
|
+
timing !== undefined && timing.firstTokenTime !== null
|
|
32
|
+
? Math.max(0, timing.completedTime - timing.firstTokenTime)
|
|
33
|
+
: null;
|
|
34
|
+
return { ttftMs, decodeMs, outputTokens: usageOutputTokens(node.usage) };
|
|
35
|
+
}
|
package/src/client/controller.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type {
|
|
|
5
5
|
SessionSnapshot,
|
|
6
6
|
} from "@deepseek-ai/dsh-api-session-controller/client";
|
|
7
7
|
import type { ObservableSnapshot, SnapshotStore } from "@deepseek-ai/dsh-client-store";
|
|
8
|
+
import type { UiWorkspace } from "@deepseek-ai/dsh-client-ui-workspace/client";
|
|
8
9
|
import { createSnapshotStore } from "@deepseek-ai/dsh-client-store";
|
|
9
10
|
import type { SessionId } from "@deepseek-ai/dsh-session";
|
|
10
11
|
import {
|
|
@@ -16,7 +17,6 @@ import {
|
|
|
16
17
|
type VersionOperation,
|
|
17
18
|
} from "../shared.ts";
|
|
18
19
|
|
|
19
|
-
/** desktop 壳(官方 dsh-desktop-host)注入的 transport 标记:ownsHost 时 API 走 /api 前缀。 */
|
|
20
20
|
function editorApiPath(): string {
|
|
21
21
|
const transport = (globalThis as { __DSH_TRANSPORT__?: { ownsHost?: boolean } })
|
|
22
22
|
.__DSH_TRANSPORT__;
|
|
@@ -26,7 +26,7 @@ function editorApiPath(): string {
|
|
|
26
26
|
export interface SessionEditorState {
|
|
27
27
|
status: "idle" | "loading" | "ready" | "error";
|
|
28
28
|
error: string | null;
|
|
29
|
-
pending: VersionOperation | "recall" |
|
|
29
|
+
pending: VersionOperation | "recall" | null;
|
|
30
30
|
timeline: SessionEditorTimeline | null;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -43,8 +43,6 @@ export interface SessionEditorFace {
|
|
|
43
43
|
reroll(): Promise<boolean>;
|
|
44
44
|
rewind(toBoundary: number): Promise<boolean>;
|
|
45
45
|
recall(message: EditableMessageBlock): Promise<boolean>;
|
|
46
|
-
|
|
47
|
-
importSession(file: File): Promise<boolean>;
|
|
48
46
|
openVersion(sessionId: string): Promise<void>;
|
|
49
47
|
}
|
|
50
48
|
|
|
@@ -53,8 +51,6 @@ function messageOf(error: unknown): string {
|
|
|
53
51
|
}
|
|
54
52
|
|
|
55
53
|
function conversationRevision(snapshot: SessionSnapshot): string {
|
|
56
|
-
// 新版 SessionSnapshot 已无 turnEnds(会话级事件窗口不再暴露轮次边界);
|
|
57
|
-
// 用生命周期字段作为会话变化指纹即可。
|
|
58
54
|
return [snapshot.openState, snapshot.removed, snapshot.hasMore].join("|");
|
|
59
55
|
}
|
|
60
56
|
|
|
@@ -69,12 +65,12 @@ export class SessionEditorController {
|
|
|
69
65
|
readonly face: SessionEditorFace;
|
|
70
66
|
private readonly ctx: ClientContext;
|
|
71
67
|
private readonly sessions: ISessions;
|
|
68
|
+
private readonly uiWorkspace: UiWorkspace;
|
|
72
69
|
private sessionSource: SessionFace | undefined;
|
|
73
70
|
private sessionSourceDispose: (() => void) | undefined;
|
|
74
71
|
private sessionRevision: string | undefined;
|
|
75
72
|
private disposed = false;
|
|
76
73
|
private users = 0;
|
|
77
|
-
private readonly navigationWaits = new Set<() => void>();
|
|
78
74
|
|
|
79
75
|
constructor(
|
|
80
76
|
ctx: ClientContext,
|
|
@@ -82,6 +78,7 @@ export class SessionEditorController {
|
|
|
82
78
|
) {
|
|
83
79
|
this.ctx = ctx;
|
|
84
80
|
this.sessions = ctx.get("sessions") as unknown as ISessions;
|
|
81
|
+
this.uiWorkspace = ctx.get("uiWorkspace") as unknown as UiWorkspace;
|
|
85
82
|
this.face = {
|
|
86
83
|
hooks: { sessionEditor: this.store },
|
|
87
84
|
acquire: () => {
|
|
@@ -109,10 +106,12 @@ export class SessionEditorController {
|
|
|
109
106
|
recall: (message) =>
|
|
110
107
|
this.mutate(
|
|
111
108
|
{ action: "recall", sessionId: this.sessionId, eventSeq: message.eventSeq },
|
|
112
|
-
() => this.setComposerDraft(message
|
|
109
|
+
() => this.setComposerDraft(this.messageTexts(message)),
|
|
113
110
|
),
|
|
114
|
-
|
|
115
|
-
|
|
111
|
+
openVersion: (sessionId) => {
|
|
112
|
+
this.uiWorkspace.openSession(sessionId as SessionId);
|
|
113
|
+
return Promise.resolve();
|
|
114
|
+
},
|
|
116
115
|
};
|
|
117
116
|
this.observe();
|
|
118
117
|
}
|
|
@@ -138,7 +137,7 @@ export class SessionEditorController {
|
|
|
138
137
|
|
|
139
138
|
private invalidate(): void {
|
|
140
139
|
if (this.disposed || this.store.getSnapshot().status === "idle") return;
|
|
141
|
-
|
|
140
|
+
|
|
142
141
|
void this.load();
|
|
143
142
|
}
|
|
144
143
|
|
|
@@ -206,8 +205,6 @@ export class SessionEditorController {
|
|
|
206
205
|
operation: SessionEditorOperation,
|
|
207
206
|
onApplied?: () => void,
|
|
208
207
|
): Promise<boolean> {
|
|
209
|
-
// 只拦截并发操作;不要求 status === "ready"——编辑/重试的数据来自客户端
|
|
210
|
-
// conversation 节点,与 timeline 加载无关,status 为 idle 时也可发起请求。
|
|
211
208
|
const current = this.store.getSnapshot();
|
|
212
209
|
if (current.pending !== null) return false;
|
|
213
210
|
this.store.update((state) => {
|
|
@@ -231,27 +228,21 @@ export class SessionEditorController {
|
|
|
231
228
|
this.store.update((state) => {
|
|
232
229
|
state.pending = null;
|
|
233
230
|
});
|
|
234
|
-
|
|
235
|
-
// 持久化 mirror 落 store,整页重载后仍能恢复。
|
|
231
|
+
|
|
236
232
|
onApplied?.();
|
|
237
233
|
const result = value as SessionEditorOperationResult;
|
|
238
|
-
|
|
234
|
+
|
|
239
235
|
if (String(result.sessionId) !== String(this.sessionId)) {
|
|
240
|
-
|
|
236
|
+
this.uiWorkspace.openSession(result.sessionId as SessionId);
|
|
241
237
|
return true;
|
|
242
238
|
}
|
|
243
|
-
|
|
244
|
-
// 回退只做增量,被剪掉的旧节点残留。优先会话级刷新(resync 重置窗口
|
|
245
|
-
// 并重新拉取历史);不可用时回退整页重载。
|
|
239
|
+
|
|
246
240
|
const face = this.sessions.binding(this.sessionId)?.session;
|
|
247
241
|
const resync = (face as unknown as { resync?: () => Promise<void> }).resync;
|
|
248
242
|
if (resync !== undefined) {
|
|
249
243
|
try {
|
|
250
244
|
await resync.call(face);
|
|
251
|
-
|
|
252
|
-
// 前的高 seq 旧值(截断后的正确值 seq 更小,永远覆盖不上),轮次
|
|
253
|
-
// 导航会残留已删除的轮次。丢弃全部投影行,等 host 推送重建;拿不到
|
|
254
|
-
// 丢弃入口时整页重载兜底。
|
|
245
|
+
|
|
255
246
|
const projections = (
|
|
256
247
|
face as unknown as { projections?: { truncate?(lastSeq: number): void } }
|
|
257
248
|
).projections;
|
|
@@ -262,9 +253,7 @@ export class SessionEditorController {
|
|
|
262
253
|
projections.truncate(-1);
|
|
263
254
|
void this.load();
|
|
264
255
|
return true;
|
|
265
|
-
} catch {
|
|
266
|
-
// fall through to full reload
|
|
267
|
-
}
|
|
256
|
+
} catch {}
|
|
268
257
|
}
|
|
269
258
|
location.reload();
|
|
270
259
|
return true;
|
|
@@ -278,97 +267,26 @@ export class SessionEditorController {
|
|
|
278
267
|
}
|
|
279
268
|
}
|
|
280
269
|
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
270
|
+
private messageTexts(message: EditableMessageBlock): readonly string[] {
|
|
271
|
+
const prefix = `${String(message.eventSeq)}:`;
|
|
272
|
+
const blocks = (this.store.getSnapshot().timeline?.messages ?? [])
|
|
273
|
+
.filter((row) => row.kind === "user" && row.key.startsWith(prefix))
|
|
274
|
+
.sort((left, right) => left.blockIndex - right.blockIndex)
|
|
275
|
+
.map((row) => row.text);
|
|
276
|
+
return blocks.length === 0 ? [message.text] : blocks;
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
private setComposerDraft(texts: readonly string[]): void {
|
|
288
280
|
if (this.sessions.binding(this.sessionId) === undefined) return;
|
|
289
281
|
const scoped = this.sessions.scope(this.sessionId);
|
|
290
282
|
if (scoped === undefined) return;
|
|
291
283
|
const conversation = scoped.get("conversation") as
|
|
292
|
-
| {
|
|
284
|
+
| {
|
|
285
|
+
input?: {
|
|
286
|
+
for(ctx: ClientContext): { restoreDraft(draft: string): void };
|
|
287
|
+
};
|
|
288
|
+
}
|
|
293
289
|
| undefined;
|
|
294
|
-
conversation?.input?.for(scoped).
|
|
295
|
-
}
|
|
296
|
-
|
|
297
|
-
private async importSession(file: File): Promise<boolean> {
|
|
298
|
-
const current = this.store.getSnapshot();
|
|
299
|
-
if (current.pending !== null) return false;
|
|
300
|
-
this.store.update((state) => {
|
|
301
|
-
state.pending = "import";
|
|
302
|
-
state.error = null;
|
|
303
|
-
});
|
|
304
|
-
try {
|
|
305
|
-
const zip = await new Promise<string>((resolve, reject) => {
|
|
306
|
-
const reader = new FileReader();
|
|
307
|
-
reader.onload = () => {
|
|
308
|
-
const dataUrl = typeof reader.result === "string" ? reader.result : "";
|
|
309
|
-
const comma = dataUrl.indexOf(",");
|
|
310
|
-
resolve(comma < 0 ? dataUrl : dataUrl.slice(comma + 1));
|
|
311
|
-
};
|
|
312
|
-
reader.onerror = () =>
|
|
313
|
-
reject(reader.error ?? new Error("failed to read the selected file"));
|
|
314
|
-
reader.readAsDataURL(file);
|
|
315
|
-
});
|
|
316
|
-
const response = await fetch("/api/session.import", {
|
|
317
|
-
method: "POST",
|
|
318
|
-
headers: { accept: "application/json", "content-type": "application/json" },
|
|
319
|
-
body: JSON.stringify({ zip, sessionId: this.sessionId }),
|
|
320
|
-
});
|
|
321
|
-
const value = (await response.json()) as unknown;
|
|
322
|
-
if (this.disposed) return true;
|
|
323
|
-
if (!response.ok) {
|
|
324
|
-
const error = (value as { error?: unknown })["error"];
|
|
325
|
-
throw new Error(
|
|
326
|
-
typeof error === "string" ? error : `请求失败:HTTP ${String(response.status)}`,
|
|
327
|
-
);
|
|
328
|
-
}
|
|
329
|
-
this.store.update((state) => {
|
|
330
|
-
state.pending = null;
|
|
331
|
-
});
|
|
332
|
-
// 覆盖成功但**不能**走 resync:resync 重开事件流时 observeSession 仍
|
|
333
|
-
// 优先读 live session(rewind 已截断其内存 log,append 只写 DB),会
|
|
334
|
-
// 再读到空/截断数据。整页重载让会话从 live 卸载,冷读 DB 完整数据。
|
|
335
|
-
location.reload();
|
|
336
|
-
return true;
|
|
337
|
-
} catch (error) {
|
|
338
|
-
if (this.disposed) return false;
|
|
339
|
-
this.store.update((state) => {
|
|
340
|
-
state.pending = null;
|
|
341
|
-
state.error = messageOf(error);
|
|
342
|
-
});
|
|
343
|
-
return false;
|
|
344
|
-
}
|
|
345
|
-
}
|
|
346
|
-
|
|
347
|
-
private openWhenListed(sessionId: SessionId): Promise<void> {
|
|
348
|
-
if (this.sessions.list.getSnapshot().byId[sessionId] !== undefined) {
|
|
349
|
-
this.sessions.open(sessionId);
|
|
350
|
-
return Promise.resolve();
|
|
351
|
-
}
|
|
352
|
-
return new Promise((resolve) => {
|
|
353
|
-
let settled = false;
|
|
354
|
-
let dispose = (): void => {};
|
|
355
|
-
const finish = (open: boolean): void => {
|
|
356
|
-
if (settled) return;
|
|
357
|
-
settled = true;
|
|
358
|
-
dispose();
|
|
359
|
-
this.navigationWaits.delete(cancel);
|
|
360
|
-
if (open) this.sessions.open(sessionId);
|
|
361
|
-
resolve();
|
|
362
|
-
};
|
|
363
|
-
const cancel = (): void => {
|
|
364
|
-
finish(false);
|
|
365
|
-
};
|
|
366
|
-
this.navigationWaits.add(cancel);
|
|
367
|
-
dispose = this.sessions.list.subscribe(() => {
|
|
368
|
-
if (this.sessions.list.getSnapshot().byId[sessionId] === undefined) return;
|
|
369
|
-
finish(true);
|
|
370
|
-
});
|
|
371
|
-
if (this.sessions.list.getSnapshot().byId[sessionId] !== undefined) finish(true);
|
|
372
|
-
});
|
|
290
|
+
conversation?.input?.for(scoped).restoreDraft(texts.join("\n\n"));
|
|
373
291
|
}
|
|
374
292
|
}
|
package/src/client/index.ts
CHANGED
|
@@ -1,15 +1,19 @@
|
|
|
1
1
|
import type { Context } from "@deepseek-ai/cordis";
|
|
2
|
-
import type {} from "@
|
|
2
|
+
import type {} from "@morlay/dsh-client-ui-conversation/client";
|
|
3
3
|
import type {} from "@deepseek-ai/dsh-client-ui-chat/client";
|
|
4
4
|
import type {} from "@deepseek-ai/dsh-client-ui-renderer/client";
|
|
5
5
|
import type { SessionId } from "@deepseek-ai/dsh-session";
|
|
6
6
|
import { SessionEditorController } from "./controller.ts";
|
|
7
7
|
import { registerChatNodeRenderers } from "./chat-node/register.ts";
|
|
8
|
-
import {
|
|
8
|
+
import { registerComposerStats } from "./composer-stats/register.ts";
|
|
9
|
+
import { styling } from "@morlay/dsh-client-ui-primitives/client";
|
|
10
|
+
import { globals as messageIconGlobals } from "./chat-node/MessageIconActions.styles.ts";
|
|
9
11
|
|
|
10
|
-
export const inject = ["slots", "conversation", "connection", "sessions"];
|
|
12
|
+
export const inject = ["slots", "conversation", "connection", "sessions", "uiWorkspace"];
|
|
11
13
|
|
|
12
14
|
export function apply(ctx: Context): void {
|
|
15
|
+
styling.injectGlobals(messageIconGlobals);
|
|
16
|
+
|
|
13
17
|
const controllers = new Map<SessionId, SessionEditorController>();
|
|
14
18
|
const controllerFor = (sessionId: SessionId): SessionEditorController => {
|
|
15
19
|
let controller = controllers.get(sessionId);
|
|
@@ -24,27 +28,7 @@ export function apply(ctx: Context): void {
|
|
|
24
28
|
for (const controller of controllers.values()) void controller.load();
|
|
25
29
|
});
|
|
26
30
|
|
|
27
|
-
// 新版 `conversation.chat.node` 是 keyed slot(reuse key 即替换该节点的
|
|
28
|
-
// 渲染器);register 内部经 ctx.slots.inject 等待声明。
|
|
29
31
|
registerChatNodeRenderers(ctx, controllerFor);
|
|
30
32
|
|
|
31
|
-
|
|
32
|
-
// 用导出的 zip 覆盖当前会话内容(host 端 `/api/session.import`)。
|
|
33
|
-
ctx.slots.inject("conversation.session.header.utilities", () =>
|
|
34
|
-
ctx.slots.register(
|
|
35
|
-
{
|
|
36
|
-
name: "conversation.session.header.utilities",
|
|
37
|
-
id: "session-editor.import",
|
|
38
|
-
inject: (sessionId: SessionId) => {
|
|
39
|
-
const face = controllerFor(sessionId).face;
|
|
40
|
-
return {
|
|
41
|
-
hooks: face.hooks,
|
|
42
|
-
// face 方法是闭包(已绑定 this),直接透传。
|
|
43
|
-
importSession: (file: File) => face.importSession(file),
|
|
44
|
-
};
|
|
45
|
-
},
|
|
46
|
-
} as never,
|
|
47
|
-
SessionImportAction as never,
|
|
48
|
-
),
|
|
49
|
-
);
|
|
33
|
+
ctx.slots.inject("conversation.composer.dock", () => registerComposerStats(ctx.slots));
|
|
50
34
|
}
|