Skip to content

Commit 2820d15

Browse files
feat(监听范围): 从学习范围里独立出来 —— 设置页单独一卡、引导单独一步
用户原话:「数字分身监听范围在设置里不应该放在学习范围钉钉里,放在独立的 数字分身监听范围的一个部分,在 onboarding 也应该加一个步骤,不和学习范围 放一起」。 监听范围(盯哪些实时消息,可随时关掉)与学习范围(学哪些历史,只增不减) 语义相反,塞在一起会让用户以为是一件事。 ## 设置页 AttentionScopePanel 从 collection-scope-panel(学习范围卡)内部摘出来, 加 standalone 模式(自带 Disclosure 卡壳、标题带渠道、去掉粘在上一块底部 的 border-t)。collect tab 里现在是三张平级卡:学习范围·渠道 / 分身监听范围·渠道 / 采集周期。collection-scope-panel 不再引用它。 CDP 实测确认三张卡平级显示。 ## 引导 新增独立步骤 attention(在 sources 之后、distill 之前):ONBOARDING_STEPS + 契约 ONBOARDING_STEP_IDS 各加一项(无迁移:step 是无 CHECK 的 TEXT 主键, list() 对缺失 step 合成 pending 行)。新组件 attention-step.tsx,候选只给 上一步已勾进学习范围的会话("监听了但不采集"这个坏状态配不出来)。 sources-step 移除内嵌的 AttentionSection。保存移到独立 attention 分支, 顺序由 STEP_ORDER 保证 sources 先跑(否则并入被 saveSource 覆盖)。
1 parent b5cc07b commit 2820d15

12 files changed

Lines changed: 653 additions & 32 deletions

File tree

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
/**
2+
* 引导步骤:**数字分身监听范围**(与学习范围分开的独立一步)。
3+
*
4+
* ## ★★★ 为什么它是独立一步,而不是塞在「学习范围」那一步里
5+
*
6+
* 用户原话:「在 onboarding 也应该加一个步骤,不和学习范围放一起」。
7+
*
8+
* 学习范围(`sources` 那一步)与监听范围语义**相反**:
9+
* · 学习范围:往回挖多少历史、只增不减;
10+
* · 监听范围:盯哪些会话的**新**消息、可随时关掉。
11+
*
12+
* 挤在同一步里,用户会以为是一件事的两个选项。分成两步、各有各的标题与
13+
* 说明,才让"学它什么"与"让它盯什么"这两个决定被分开表达。
14+
*
15+
* ## ★★ 候选**只有已勾进学习范围**的会话(前置防护)
16+
*
17+
* 「监听了但不采集」是一个能配出来的坏状态:`admit()` 判该不该回要读历史
18+
* (`message_mentions`、这个会话之前的往来),所以分身会收到消息却拿不到
19+
* 任何上下文,于是不回或回得离谱 —— 而用户看不出成因。
20+
*
21+
* 把候选限定在"上一步已勾选的会话",那个坏状态在引导里**配不出来**。
22+
* 这也是这一步必须排在 `sources` **之后**的原因:候选来自上一步的选择。
23+
*
24+
* ## ★ 与 `sources-step` 的 `AttentionSection` 是同一段逻辑
25+
*
26+
* 它原来嵌在学习范围那一步里(作为下半块)。拆成独立步骤后,那一段挪到
27+
* 这里,`sources-step` 不再引用它 —— 判据只有一份,不复制。
28+
*/
29+
import { useMemo } from "react"
30+
import { Button, Checkbox } from "@mycontext/design"
31+
import type { ChannelConversationView } from "@mycontext/ipc-contract"
32+
import { useChannelConversations } from "../../lib/queries.js"
33+
import { useDynamicTranslation } from "../../lib/use-dynamic-translation.js"
34+
import { StepSection } from "./step-section.js"
35+
import type { SourcesDraft } from "./sources-step.js"
36+
37+
export interface AttentionStepProps {
38+
value: SourcesDraft
39+
onChange: (next: SourcesDraft) => void
40+
/**
41+
* 只列这些渠道的会话(与 `SourcesStep` 同一个判据)。
42+
* 候选还要再过一道"已勾进学习范围",见下。
43+
*/
44+
channelFilter?: ReadonlySet<string>
45+
}
46+
47+
export function AttentionStep({ value, onChange, channelFilter }: AttentionStepProps) {
48+
const { t } = useDynamicTranslation("onboarding")
49+
const conversations = useChannelConversations(true)
50+
51+
/**
52+
* 候选 = **已勾进学习范围**(上一步)且属于当前渠道的会话。
53+
*
54+
* ★ 两道过滤:先按 `channelFilter` 收窄到已连渠道(与学习范围一致),
55+
* 再取其中 `conversationIds` 里有的 —— 那是"上一步选了要学的"。
56+
* 没勾进学习范围的会话不该出现在这里(见文件头的前置防护)。
57+
*/
58+
const candidates = useMemo<readonly ChannelConversationView[]>(() => {
59+
const all = conversations.data?.items ?? []
60+
const inChannel =
61+
channelFilter === undefined
62+
? all
63+
: all.filter((item) => item.channelId === undefined || channelFilter.has(item.channelId))
64+
const learned = new Set(value.conversationIds)
65+
return inChannel.filter((item) => learned.has(item.externalId))
66+
}, [conversations.data, channelFilter, value.conversationIds])
67+
68+
const chosen = new Set(value.attentionConversationIds)
69+
const toggle = (externalId: string): void => {
70+
const next = chosen.has(externalId)
71+
? value.attentionConversationIds.filter((id) => id !== externalId)
72+
: [...value.attentionConversationIds, externalId]
73+
onChange({ ...value, attentionConversationIds: next })
74+
}
75+
76+
return (
77+
<StepSection
78+
title={t("attentionStep.title", { defaultValue: "数字分身监听范围" })}
79+
hint={t("attentionStep.hint", {
80+
defaultValue:
81+
"分身只处理这些会话从现在起的新消息。与上一步的学习范围不同:它不看历史,也可以随时关掉。",
82+
})}
83+
action={
84+
value.attentionConversationIds.length > 0 ? (
85+
<span className="flex items-center gap-2">
86+
<span className="typography-caption-400 text-[var(--text-base-tertiary)]">
87+
{t("attentionStep.selected", {
88+
defaultValue: "盯 {{count}} 个会话",
89+
count: value.attentionConversationIds.length,
90+
})}
91+
</span>
92+
<Button
93+
size="sm"
94+
variant="ghost"
95+
onClick={() => onChange({ ...value, attentionConversationIds: [] })}
96+
>
97+
{t("attentionStep.clear", { defaultValue: "取消全部监听" })}
98+
</Button>
99+
</span>
100+
) : undefined
101+
}
102+
>
103+
{candidates.length === 0 ? (
104+
/**
105+
* ★ 候选为空时说清**为什么**,而不是给一个空列表。
106+
* 空列表读起来像"坏了",而真相是"回上一步勾几个会话" ——
107+
* 那是一个用户能立刻执行的动作。
108+
*/
109+
<p className="typography-body-small-400 text-[var(--text-base-tertiary)]">
110+
{t("attentionStep.empty", {
111+
defaultValue:
112+
"上一步还没勾选要学习的会话 —— 先选好学习范围,再从中挑出让分身盯着的那些。",
113+
})}
114+
</p>
115+
) : (
116+
<div className="flex flex-col gap-2">
117+
{/*
118+
★★ 「不勾任何一个」是一个**有意义**的选择,必须说出来。
119+
120+
存量行为是"名单为空 → 全部放行"(迁移期的正确一侧,见
121+
`AttentionRouter.route`)。所以不勾 = 分身盯所有已学习的会话。
122+
不说这句,用户以为"不勾就是不启用",而事实相反。
123+
*/}
124+
<p className="typography-caption-400 text-[var(--text-base-tertiary)]">
125+
{t("attentionStep.emptyMeaning", {
126+
defaultValue: "一个都不勾 = 分身会盯上一步所有已勾选的会话(可稍后在设置里收窄)。",
127+
})}
128+
</p>
129+
<ul className="flex flex-col gap-1">
130+
{candidates.map((item) => (
131+
<li key={item.externalId}>
132+
<Checkbox
133+
checked={chosen.has(item.externalId)}
134+
onChange={() => toggle(item.externalId)}
135+
label={item.title ?? item.externalId.slice(0, 8)}
136+
/>
137+
</li>
138+
))}
139+
</ul>
140+
</div>
141+
)}
142+
</StepSection>
143+
)
144+
}

‎apps/desktop/src/renderer/features/onboarding/onboarding-view.tsx‎

Lines changed: 83 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,7 @@ import {
3939
useIngestSnapshot,
4040
useOnboardingSteps,
4141
useSaveDistillSource,
42+
useSaveAttentionScope,
4243
usePersonaSnapshot,
4344
useSkipOnboarding,
4445
useSkipStep,
@@ -53,6 +54,7 @@ import { readPersonaIdentity } from "../persona/persona-identity.js"
5354
import { DistillStep } from "./distill-step.js"
5455
import { PersonaStep, type PersonaDraft } from "./persona-step.js"
5556
import { SourcesStep, type SourcesDraft } from "./sources-step.js"
57+
import { AttentionStep } from "./attention-step.js"
5658
import { ModelConfigForm } from "../settings/model-config-form.js"
5759
import { canRunPersona, personaCapableChannels } from "../../lib/channel-capability.js"
5860
import { ChannelPicker } from "../shell/channel-picker.js"
@@ -62,15 +64,17 @@ const STEP_ORDER: readonly OnboardingStepId[] = [
6264
"model",
6365
"persona",
6466
"sources",
67+
"attention",
6568
"distill",
6669
]
6770

68-
/** 各步是否已实装。五步都通了(第 5 步是真进度,不是占位)。 */
71+
/** 各步是否已实装。 */
6972
const IMPLEMENTED: Record<OnboardingStepId, boolean> = {
7073
channel: true,
7174
model: true,
7275
persona: true,
7376
sources: true,
77+
attention: true,
7478
distill: true,
7579
}
7680

@@ -80,6 +84,19 @@ const DEFAULT_SOURCES: SourcesDraft = {
8084
conversationIds: [],
8185
// 缺省只勾**已接入**的两个:勾上未接入的等于给一个不会兑现的承诺
8286
enabledSources: ["chat", "minutes"],
87+
/**
88+
* ★★ 缺省**不勾任何监听会话**,而它的含义是"盯全部已学习的会话"。
89+
*
90+
* 判据在 `AttentionRouter.route()`:名单为空 → 放行(迁移期的正确一侧,
91+
* 否则存量用户的分身会整个静默)。所以这个空数组不是"关掉分身",
92+
* 而是"不收窄"—— 界面上必须把这句话说出来(见 `AttentionSection` 里那段
93+
* `attentionEmptyMeaning`),否则用户会以为不勾就等于不启用。
94+
*
95+
* ★ 为什么不缺省勾全部:那样保存时会写一份**具体的**名单进
96+
* `attention_scope`,而 `enabled_at` 只能变早、名单只增 ——
97+
* 等于替用户做了一个比"不收窄"更难撤回的决定。
98+
*/
99+
attentionConversationIds: [],
83100
}
84101

85102
/**
@@ -120,6 +137,8 @@ function readSources(row: OnboardingStepView | undefined): SourcesDraft {
120137
chatKinds?: unknown
121138
conversationIds?: unknown
122139
enabledSources?: unknown
140+
/** 监听会话(v29 这一轮新加;旧 payload 里没有 → `strings()` 给空数组) */
141+
attentionConversationIds?: unknown
123142
}
124143
const strings = (value: unknown): string[] =>
125144
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : []
@@ -151,6 +170,12 @@ function readSources(row: OnboardingStepView | undefined): SourcesDraft {
151170
),
152171
conversationIds: strings(record.conversationIds),
153172
enabledSources: strings(record.enabledSources) as DistillSourceId[],
173+
/**
174+
* ★ 回填监听勾选。旧的 payload 里没有这个键 → 空数组(= 不收窄),
175+
* 与 `DEFAULT_SOURCES` 一致。给一个"全选"会让重进引导时凭空多出一批
176+
* 监听会话,而用户没有做过那个选择。
177+
*/
178+
attentionConversationIds: strings(record.attentionConversationIds),
154179
}
155180
}
156181

@@ -185,6 +210,8 @@ export function OnboardingView() {
185210
const completeStep = useCompleteStep()
186211
const skipStep = useSkipStep()
187212
const saveSource = useSaveDistillSource()
213+
/** 监听范围(分身盯哪些会话的新消息)。见 `advance()` 里那段顺序注释。 */
214+
const saveAttention = useSaveAttentionScope()
188215
const complete = useCompleteOnboarding()
189216
const skip = useSkipOnboarding()
190217
/** 只为拿 `agentAvailable`(是否配了模型)—— 第 4 步要据此提前提示。 */
@@ -515,6 +542,48 @@ export function OnboardingView() {
515542
}
516543
}
517544
completeStep.mutate({ step: "sources", payload: sources })
545+
} else if (activeId === "attention") {
546+
/**
547+
* ★★★ 监听范围:**独立一步**保存(不再夹在 sources 分支里)。
548+
*
549+
* ## 顺序仍然重要:attention 排在 sources 之后
550+
*
551+
* `attentionScopeSave` 会把勾中的会话**并入学习范围的白名单**
552+
* (`source: 'learning'`)—— 消灭"监听了但不采集"这个坏状态(分身收到
553+
* 消息却拿不到上下文,于是不回或回得离谱,而用户看不出成因)。
554+
*
555+
* 步骤顺序保证了 sources 的 `saveSource` 已经先跑过(上一步),
556+
* 所以这里的并入不会被它覆盖。
557+
*
558+
* ## ★★ 空数组时**不调**这个接口
559+
*
560+
* 契约要求 `conversationExternalIds` 至少一个(`.min(1)`)。
561+
* 语义上空 = "不收窄"(名单为空 → 路由放行全部)。调一次空的保存
562+
* 既会报错,也会让人以为"存了一个空名单"(而表本就是空的)。
563+
*
564+
* ## ★ 按渠道分桶
565+
*
566+
* `attention_scope` 是 per-channel 表,勾选列表混着多个渠道。整批塞给
567+
* 一个渠道 = 写进一批**不存在的 id**,路由永远命中不了且不报错。
568+
*/
569+
if (sources.attentionConversationIds.length > 0) {
570+
const channelOf = new Map(
571+
(conversationList.data?.items ?? []).map((item) => [item.externalId, item.channelId]),
572+
)
573+
const attentionBuckets = new Map<string, string[]>()
574+
for (const id of sources.attentionConversationIds) {
575+
const channelId = channelOf.get(id) ?? PRIMARY_CHANNEL_ID
576+
attentionBuckets.set(channelId, [...(attentionBuckets.get(channelId) ?? []), id])
577+
}
578+
for (const [channelId, ids] of attentionBuckets) {
579+
/**
580+
* ★ 不传 `enabledAt` —— 让**主进程的时钟**决定"从现在开始"
581+
* (见 `attentionScopeSave` 的注释)。
582+
*/
583+
saveAttention.mutate({ channelId, conversationExternalIds: ids })
584+
}
585+
}
586+
completeStep.mutate({ step: "attention", payload: { ids: sources.attentionConversationIds } })
518587
} else if (activeId === "channel") {
519588
completeStep.mutate({ step: "channel" })
520589
} else if (activeId === "model") {
@@ -793,6 +862,19 @@ export function OnboardingView() {
793862
/>
794863
) : null}
795864

865+
{activeId === "attention" ? (
866+
/**
867+
* ★ 独立步骤:分身监听范围。候选来自上一步(sources)的勾选 ——
868+
* 所以 `value` 就是同一份 `sources` 草稿(它带 `conversationIds`
869+
* 与 `attentionConversationIds`)。`channelFilter` 与上一步一致。
870+
*/
871+
<AttentionStep
872+
value={sources}
873+
onChange={setSources}
874+
channelFilter={authorizedChannelIds}
875+
/>
876+
) : null}
877+
796878
{activeId === "distill" ? (
797879
<DistillStep
798880
rangeDays={sources.rangeDays}

‎apps/desktop/src/renderer/features/onboarding/sources-step.tsx‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,6 +100,36 @@ export interface SourcesDraft {
100100
conversationIds: string[]
101101
/** 勾选了哪些资料源 */
102102
enabledSources: DistillSourceId[]
103+
/**
104+
* ── 数字分身的**监听范围** ────────────────────────────────────
105+
*
106+
* ## ★★★ 为什么它必须在这一步,而不是另开一步或只留在设置页
107+
*
108+
* 改动前监听范围**只在运行状态页能配**。于是走完引导的用户
109+
* `attention_scope` 是空表 → 路由走"名单为空则放行"那一档
110+
* → 分身对**所有**会话的新消息起草。那不是用户选的,是缺省值替他选的。
111+
*
112+
* 而它与上面的学习范围是**同一个决定的两面**("学它什么" / "让它盯什么"),
113+
* 所以放在同一步、上下两块:
114+
*
115+
* · 另开一步的话,用户要对着同一份会话列表勾第二遍,而两次勾选不一致时
116+
* 没人知道哪个对;
117+
* · 只留在设置页的话,绝大多数用户永远不会去配它。
118+
*
119+
* ## ★★ 与 `conversationIds`(学习范围)的三处**刻意不同**
120+
*
121+
* | | 学习范围 | 监听范围 |
122+
* |---|---|---|
123+
* | 管什么 | 往回挖多少历史 | 盯哪些会话的**新**消息 |
124+
* | 时间 | `since` 往回 | `enabledAt` 从保存那刻往后 |
125+
* | 能不能收回 | **不能**(图谱已消费) | **能**(不存历史,关掉无副作用) |
126+
*
127+
* ★ 勾这里会**自动并入**学习范围(`attentionScopeSave` 那侧做的):
128+
* 「监听了但不采集」是一个能配出来的坏状态 —— 分身收到消息却拿不到上下文
129+
* (`admit()` 要读 `message_mentions` 与这个会话之前的往来),
130+
* 于是它不回或回得离谱,而用户完全看不出成因。
131+
*/
132+
attentionConversationIds: string[]
103133
}
104134

105135
export interface SourcesStepProps {
@@ -490,7 +520,15 @@ export function SourcesStep({
490520
)}
491521
</StepSection>
492522

493-
{/* 资料源勾选 */}
523+
{/*
524+
★★ 监听范围**不再**在这一步了(拆成了独立的引导步骤 `attention`)。
525+
526+
用户原话:「在 onboarding 也应该加一个步骤,不和学习范围放一起」。
527+
它原来作为这一步的下半块 —— 而学习范围(只增不减)与监听范围
528+
(可随时关掉)语义相反,同屏会让用户以为是一件事。现在它是
529+
`AttentionStep`(独立组件),排在本步之后(候选来自本步的勾选)。
530+
*/}
531+
494532
<StepSection
495533
title={t("sourcesStep.sectionSources")}
496534
hint={t("sourcesStep.sectionSourcesHint")}

‎apps/desktop/src/renderer/features/settings/settings-view.tsx‎

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,7 @@ import { useDynamicTranslation } from "../../lib/use-dynamic-translation.js"
5151
import { personaCapableChannels } from "../../lib/channel-capability.js"
5252
import { ChannelPicker } from "../shell/channel-picker.js"
5353
import { CollectionScopePanel } from "../shell/collection-scope-panel.js"
54+
import { AttentionScopePanel } from "../shell/attention-scope-panel.js"
5455
import { KlPanel } from "../shell/status-panel.js"
5556
import { IngestIntervalsPanel } from "./ingest-intervals-panel.js"
5657

@@ -1065,9 +1066,17 @@ function ChannelsSection({
10651066
</p>
10661067
) : tab === "collect" ? (
10671068
<>
1068-
{/* ① 采什么 —— 按渠道 */}
1069+
{/* ① 学它哪些历史 —— 按渠道,只增不减 */}
10691070
<CollectionScopePanel channelId={activeChannel.id} />
1070-
{/* ② 多久采一次 —— **全局**,见本组件文件头那段 */}
1071+
{/*
1072+
② 分身盯哪些实时消息 —— **独立一张卡**,与学习范围平级。
1073+
1074+
用户原话:「不应该放在学习范围钉钉里,放在独立的…一个部分」。
1075+
它与学习范围语义相反(只增不减 vs 可随时关掉),塞在同一张卡里
1076+
会让"学历史"与"盯实时"混成一件事。`standalone` 让它自带卡壳。
1077+
*/}
1078+
<AttentionScopePanel channelId={activeChannel.id} standalone />
1079+
{/* ③ 多久采一次 —— **全局**,见本组件文件头那段 */}
10711080
<IngestIntervalsPanel />
10721081
<p className="typography-caption-400 text-[var(--text-base-tertiary)]">
10731082
{t("channelData.intervalsGlobalNote")}

0 commit comments

Comments
 (0)