feat(collab): free-lane chat, claim-on-view, lease ownership, notify inbox

Open member free-lane for chat initiation; auto-claim on author view;
harden lease persist/ownership with readable DOC_BUSY; add notification
inbox and supplement-impact HTTP decision APIs wired to composition root.
This commit is contained in:
weichen
2026-09-24 11:49:43 +00:00
parent d0b4d3d4dd
commit 143ee934b8
51 changed files with 2170 additions and 230 deletions
+1
View File
@@ -29,6 +29,7 @@ export interface DocGovRecord {
approver_id?: number;
approver_name?: string;
author_claimed?: boolean;
author_claimed_at?: string;
reviewer_claimed?: boolean;
approver_claimed?: boolean;
last_rejected_reason?: string;
+46
View File
@@ -0,0 +1,46 @@
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { isProposalType, sortInboxNotifications, type UserNotification } from './notifications';
describe('notifications inbox helpers', () => {
it('ranks action_required / proposal ahead of info', () => {
const items: UserNotification[] = [
{
id: 1,
user_id: 1,
project_id: 1,
type: 'stage_ready_to_advance',
title: 'a',
body: '',
status: 'unread',
severity: 'info',
created_at: '',
},
{
id: 2,
user_id: 1,
project_id: 1,
type: 'document_proposal_required',
title: 'b',
body: '',
status: 'unread',
severity: 'action_required',
created_at: '',
},
{
id: 3,
user_id: 1,
project_id: 1,
type: 'document_unclaimed_edit_applied',
title: 'c',
body: '',
status: 'unread',
severity: 'info',
created_at: '',
},
];
const sorted = sortInboxNotifications(items);
assert.equal(sorted[0].id, 2);
assert.equal(isProposalType(sorted[0].type), true);
});
});
+154
View File
@@ -0,0 +1,154 @@
import { apiFetch, API_BASE } from './client';
import { getBearerAuthHeaders } from './authHeaders';
export interface UserNotification {
id: number;
user_id: number;
project_id: number;
type: string;
title: string;
body: string;
status: string;
severity?: 'action_required' | 'info' | string;
digest_id?: number;
created_at: string;
payload?: {
stage_key?: string;
stage_name?: string;
config_revision?: number;
workflow_instance_id?: number;
doc_id?: string;
proposal_id?: string;
batch_id?: string;
};
}
export function sortInboxNotifications(items: UserNotification[]): UserNotification[] {
return [...items].sort((a, b) => {
const sa = a.severity === 'action_required' || isProposalType(a.type) ? 0 : 1;
const sb = b.severity === 'action_required' || isProposalType(b.type) ? 0 : 1;
if (sa !== sb) return sa - sb;
return b.id - a.id;
});
}
export function isProposalType(type: string): boolean {
return (
type === 'proposal_required' ||
type === 'document_proposal_required' ||
type === 'digest_proposal_required' ||
type.includes('proposal_required')
);
}
export async function fetchAllNotifications(opts?: {
sinceId?: number;
limit?: number;
scope?: 'all';
}): Promise<UserNotification[]> {
const params = new URLSearchParams();
if (opts?.sinceId && opts.sinceId > 0) params.set('since_id', String(opts.sinceId));
if (opts?.limit) params.set('limit', String(opts.limit));
if (opts?.scope) params.set('scope', opts.scope);
const qs = params.toString();
const res = await apiFetch(`${API_BASE}/api/v1/notifications${qs ? `?${qs}` : ''}`, {
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
if (res.status === 503) return [];
const text = await res.text();
throw new Error(text || `获取通知失败: ${res.status}`);
}
const data = await res.json();
const items = Array.isArray(data.items) ? (data.items as UserNotification[]) : [];
return sortInboxNotifications(items);
}
export async function patchNotificationStatus(
id: number,
status: 'read' | 'acted' | 'dismissed' | 'unread'
): Promise<void> {
const res = await apiFetch(`${API_BASE}/api/v1/notifications/${id}`, {
method: 'PATCH',
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify({ status }),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `更新通知失败: ${res.status}`);
}
}
export interface SupplementImpactItem {
doc_id: string;
title?: string;
impact_type?: string;
reason?: string;
author_claimed_snapshot?: boolean;
busy_snapshot?: boolean;
change_summary?: string;
selected?: boolean;
outcome?: string;
proposal_id?: string;
}
export interface SupplementImpactBatch {
batch_id: string;
project_id: number;
session_id?: string;
initiator_user_id: number;
source_summary?: string;
coverage_status?: string;
status: string;
decision_nonce: string;
decision_digest?: string;
revision: number;
expires_at: string;
items?: SupplementImpactItem[];
}
export async function fetchSupplementImpact(batchId: string): Promise<SupplementImpactBatch> {
const res = await apiFetch(`${API_BASE}/api/v1/supplement-impacts/${encodeURIComponent(batchId)}`, {
headers: getBearerAuthHeaders(),
});
if (!res.ok) {
const text = await res.text();
throw new Error(text || `加载补充影响批次失败: ${res.status}`);
}
return res.json();
}
export async function decideSupplementImpact(
batchId: string,
body: {
kind: 'confirm' | 'cancel';
selected_doc_ids: string[];
decision_nonce: string;
decision_digest: string;
expected_revision: number;
}
): Promise<SupplementImpactBatch> {
const res = await apiFetch(
`${API_BASE}/api/v1/supplement-impacts/${encodeURIComponent(batchId)}/decision`,
{
method: 'POST',
headers: getBearerAuthHeaders('application/json'),
body: JSON.stringify(body),
}
);
if (!res.ok) {
const text = await res.text();
let message = text || `决策失败: ${res.status}`;
try {
const parsed = JSON.parse(text) as { message?: string; error?: string };
if (parsed.message) message = parsed.message;
else if (parsed.error) message = parsed.error;
} catch {
/* keep */
}
const err = new Error(message) as Error & { status?: number };
err.status = res.status;
throw err;
}
return res.json();
}
@@ -59,6 +59,10 @@ describe('InteractiveCard Logic', () => {
const planMeta = getCardTypeMeta('PLAN_CONFIRM');
expect(planMeta.label).toBe('计划确认');
expect(planMeta.badgeText).toContain('计划');
const impactMeta = getCardTypeMeta('SUPPLEMENT_IMPACT_CONFIRM');
expect(impactMeta.label).toContain('补充影响');
expect(impactMeta.badgeText).toContain('影响');
});
});
@@ -92,6 +92,16 @@ export function getCardTypeMeta(type: InteractiveCardType) {
badgeColor: 'var(--amber-600)',
badgeBorder: 'rgba(242, 169, 59, 0.3)',
};
case 'SUPPLEMENT_IMPACT_CONFIRM':
return {
label: '补充影响确认',
badgeText: '影响确认',
icon: FileQuestion,
accentColor: 'var(--brand-500)',
badgeBg: 'var(--brand-50)',
badgeColor: 'var(--brand-600)',
badgeBorder: 'var(--brand-100)',
};
default:
return {
label: '协同卡片',
@@ -566,6 +576,89 @@ export const InteractiveCard: React.FC<InteractiveCardProps> = ({
</div>
)}
{card.cardType === 'SUPPLEMENT_IMPACT_CONFIRM' && Array.isArray(card.impactItems) && (
<div
data-testid="supplement-impact-checklist"
style={{
display: 'flex',
flexDirection: 'column',
gap: '6px',
background: 'var(--bg-subtle)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-sm)',
padding: '8px 10px',
}}
>
{card.coverageStatus && (
<div style={{ fontSize: '11px', color: 'var(--text-muted)', marginBottom: 2 }}>
覆盖状态:{card.coverageStatus}
</div>
)}
{card.impactItems.map((item) => (
<label
key={item.docId}
style={{
display: 'flex',
alignItems: 'flex-start',
gap: '8px',
fontSize: '12px',
color: item.busy ? 'var(--text-muted)' : 'var(--text-strong)',
opacity: item.busy ? 0.55 : 1,
cursor: item.busy || !canClick ? 'not-allowed' : 'pointer',
}}
>
<input
type="checkbox"
disabled={item.busy || !canClick}
checked={!!item.selected}
onChange={(e) => {
if (!onExecuteAction || item.busy) return;
onExecuteAction(card.cardId, 'toggle_impact_doc', {
docId: item.docId,
selected: e.target.checked,
});
}}
style={{ marginTop: 2 }}
/>
<span style={{ flex: 1 }}>
<span style={{ fontWeight: 600 }}>{item.title || item.docId}</span>
{item.authorClaimed && (
<span
style={{
marginLeft: 6,
fontSize: '10.5px',
fontWeight: 600,
color: 'var(--amber-600)',
background: 'var(--amber-50)',
padding: '1px 6px',
borderRadius: 'var(--r-pill)',
}}
>
已认领
</span>
)}
{item.busy && (
<span
style={{
marginLeft: 6,
fontSize: '10.5px',
color: 'var(--text-muted)',
}}
>
忙碌中
</span>
)}
{item.changeSummary && (
<div style={{ color: 'var(--text-muted)', marginTop: 2, lineHeight: 1.4 }}>
{item.changeSummary}
</div>
)}
</span>
</label>
))}
</div>
)}
{/* 拟建议修改内容 / 批注展示 */}
{card.proposedContent && (
<div
@@ -33,6 +33,7 @@ interface DocGovActionBarProps {
export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
ast,
currentRole,
currentUserId,
canOverrideVisibility = true,
onOpenVisibilityModal,
@@ -345,6 +346,34 @@ export const DocGovActionBar: React.FC<DocGovActionBarProps> = ({
• 已被编制人认领
</span>
)}
{ast.isClaimed &&
currentRole === 'author' &&
(ast.authorId === undefined || currentUserId === undefined || ast.authorId === currentUserId) && (
<span
style={{
fontSize: '11px',
fontWeight: 600,
padding: '2px 8px',
borderRadius: 'var(--r-pill)',
background: 'rgba(33, 166, 106, 0.12)',
color: 'var(--success, #21a66a)',
maxWidth: '280px',
lineHeight: 1.35,
}}
title="认领后他人发起的修改需您确认"
>
您已于{' '}
{ast.authorClaimedAt
? new Date(ast.authorClaimedAt).toLocaleString('zh-CN', {
month: 'numeric',
day: 'numeric',
hour: '2-digit',
minute: '2-digit',
})
: '此前'}{' '}
认领本文档;此后他人发起的修改需您确认。
</span>
)}
{ast.visibilityRule && ast.visibilityRule.mode !== 'inherit' && (
<span
style={{
+3
View File
@@ -2,6 +2,7 @@ import React from 'react';
import { Sun, Moon, PanelLeftClose, PanelLeftOpen, PanelRightClose, PanelRightOpen, Sparkles } from 'lucide-react';
import { ProjectSession } from '../../types/workbench';
import { ProjectWorkflowSwitcher } from '../workflow/ProjectWorkflowSwitcher';
import { NotificationInbox } from '../notifications/NotificationInbox';
interface TopbarProps {
currentSession: ProjectSession;
@@ -165,6 +166,8 @@ export const Topbar: React.FC<TopbarProps> = ({
/>
</div>
<NotificationInbox />
{/* 主题切换按钮 */}
<button
onClick={onToggleTheme}
@@ -0,0 +1,349 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react';
import { Bell, CheckCircle2, ExternalLink, X } from 'lucide-react';
import {
fetchAllNotifications,
isProposalType,
patchNotificationStatus,
type UserNotification,
} from '../../api/notifications';
import { decideModificationProposal } from '../../api/docgov';
const POLL_MS = 20_000;
export interface NotificationInboxProps {
onJumpProject?: (projectId: number, payload?: UserNotification['payload']) => void;
}
export const NotificationInbox: React.FC<NotificationInboxProps> = ({ onJumpProject }) => {
const [open, setOpen] = useState(false);
const [items, setItems] = useState<UserNotification[]>([]);
const [busyId, setBusyId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
const load = useCallback(async () => {
try {
const next = await fetchAllNotifications({ limit: 50, scope: 'all' });
setItems(next);
setError(null);
} catch (e) {
setError(e instanceof Error ? e.message : '加载通知失败');
}
}, []);
useEffect(() => {
void load();
const timer = window.setInterval(() => {
void load();
}, POLL_MS);
return () => window.clearInterval(timer);
}, [load]);
const unread = useMemo(
() => items.filter((n) => n.status === 'unread').length,
[items]
);
const actionItems = useMemo(
() =>
items.filter(
(n) => n.severity === 'action_required' || isProposalType(n.type)
),
[items]
);
const infoItems = useMemo(
() =>
items.filter(
(n) => !(n.severity === 'action_required' || isProposalType(n.type))
),
[items]
);
const markRead = async (n: UserNotification) => {
if (n.status !== 'unread') return;
try {
await patchNotificationStatus(n.id, 'read');
setItems((prev) => prev.map((x) => (x.id === n.id ? { ...x, status: 'read' } : x)));
} catch {
/* ignore */
}
};
const decideProposal = async (n: UserNotification, approve: boolean) => {
const proposalId = n.payload?.proposal_id;
if (!proposalId) return;
setBusyId(n.id);
try {
await decideModificationProposal({ proposal_id: proposalId, approve });
await patchNotificationStatus(n.id, 'acted');
setItems((prev) => prev.map((x) => (x.id === n.id ? { ...x, status: 'acted' } : x)));
} catch (e) {
setError(e instanceof Error ? e.message : '提案决策失败');
} finally {
setBusyId(null);
}
};
return (
<div style={{ position: 'relative' }}>
<button
type="button"
onClick={() => setOpen((v) => !v)}
title="通知收件箱"
data-testid="notification-inbox-bell"
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
width: '32px',
height: '32px',
borderRadius: 'var(--r-sm)',
color: 'var(--text-muted)',
background: open ? 'var(--brand-50)' : 'var(--bg-subtle)',
border: '1px solid var(--border)',
position: 'relative',
}}
>
<Bell size={16} />
{unread > 0 && (
<span
data-testid="notification-unread-badge"
style={{
position: 'absolute',
top: '-4px',
right: '-4px',
minWidth: '16px',
height: '16px',
padding: '0 4px',
borderRadius: '999px',
background: 'var(--danger, #dc2626)',
color: '#fff',
fontSize: '10px',
fontWeight: 700,
lineHeight: '16px',
textAlign: 'center',
}}
>
{unread > 99 ? '99+' : unread}
</span>
)}
</button>
{open && (
<div
data-testid="notification-inbox-panel"
style={{
position: 'absolute',
top: '40px',
right: 0,
width: '360px',
maxHeight: '480px',
overflow: 'auto',
background: 'var(--bg-panel)',
border: '1px solid var(--border)',
borderRadius: 'var(--r-md)',
boxShadow: 'var(--shadow-md, 0 8px 24px rgba(15,23,42,0.12))',
zIndex: 50,
padding: '10px 0',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
padding: '0 12px 8px',
borderBottom: '1px solid var(--border)',
}}
>
<span style={{ fontSize: '13px', fontWeight: 700, color: 'var(--text-strong)' }}>
通知
</span>
<button
type="button"
onClick={() => setOpen(false)}
style={{
border: 'none',
background: 'transparent',
color: 'var(--text-muted)',
cursor: 'pointer',
padding: 4,
}}
>
<X size={14} />
</button>
</div>
{error && (
<div style={{ padding: '8px 12px', fontSize: '12px', color: 'var(--danger, #dc2626)' }}>
{error}
</div>
)}
{actionItems.length > 0 && (
<section style={{ padding: '8px 0' }}>
<div
style={{
padding: '4px 12px',
fontSize: '11px',
fontWeight: 700,
color: 'var(--amber-600, #d97706)',
letterSpacing: '0.02em',
}}
>
需要处理
</div>
{actionItems.map((n) => (
<NotificationRow
key={n.id}
n={n}
busy={busyId === n.id}
onOpen={() => {
void markRead(n);
onJumpProject?.(n.project_id, n.payload);
}}
onDecide={
isProposalType(n.type) && n.payload?.proposal_id
? (approve) => void decideProposal(n, approve)
: undefined
}
/>
))}
</section>
)}
<section style={{ padding: '8px 0' }}>
<div
style={{
padding: '4px 12px',
fontSize: '11px',
fontWeight: 700,
color: 'var(--text-muted)',
}}
>
摘要
</div>
{infoItems.length === 0 && actionItems.length === 0 ? (
<div style={{ padding: '16px 12px', fontSize: '12px', color: 'var(--text-muted)' }}>
暂无通知
</div>
) : (
infoItems.map((n) => (
<NotificationRow
key={n.id}
n={n}
busy={false}
onOpen={() => {
void markRead(n);
onJumpProject?.(n.project_id, n.payload);
}}
/>
))
)}
</section>
</div>
)}
</div>
);
};
function NotificationRow({
n,
busy,
onOpen,
onDecide,
}: {
n: UserNotification;
busy: boolean;
onOpen: () => void;
onDecide?: (approve: boolean) => void;
}) {
const unread = n.status === 'unread';
return (
<div
style={{
padding: '10px 12px',
borderBottom: '1px solid var(--border)',
background: unread ? 'var(--brand-50, rgba(79,107,255,0.06))' : 'transparent',
opacity: busy ? 0.6 : 1,
}}
>
<div style={{ display: 'flex', gap: 8, alignItems: 'flex-start' }}>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
fontSize: '12.5px',
fontWeight: unread ? 700 : 600,
color: 'var(--text-strong)',
marginBottom: 2,
}}
>
{n.title}
</div>
<div
style={{
fontSize: '11.5px',
color: 'var(--text-muted)',
lineHeight: 1.45,
whiteSpace: 'pre-wrap',
}}
>
{n.body}
</div>
</div>
<button
type="button"
onClick={onOpen}
title="跳转"
style={{
border: 'none',
background: 'transparent',
color: 'var(--brand-600)',
cursor: 'pointer',
padding: 2,
}}
>
<ExternalLink size={14} />
</button>
</div>
{onDecide && n.status !== 'acted' && (
<div style={{ display: 'flex', gap: 8, marginTop: 8 }}>
<button
type="button"
disabled={busy}
onClick={() => onDecide(true)}
style={{
fontSize: '11.5px',
fontWeight: 600,
padding: '4px 10px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--brand-100)',
background: 'var(--brand-500)',
color: '#fff',
cursor: busy ? 'not-allowed' : 'pointer',
}}
>
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 4 }}>
<CheckCircle2 size={12} /> 同意修改
</span>
</button>
<button
type="button"
disabled={busy}
onClick={() => onDecide(false)}
style={{
fontSize: '11.5px',
fontWeight: 600,
padding: '4px 10px',
borderRadius: 'var(--r-sm)',
border: '1px solid var(--border)',
background: 'var(--bg-subtle)',
color: 'var(--text-muted)',
cursor: busy ? 'not-allowed' : 'pointer',
}}
>
拒绝
</button>
</div>
)}
</div>
);
}
+17
View File
@@ -153,6 +153,23 @@ test('read-only current-doc request does not start a task', () => {
);
});
test('summary/analysis with write verbs stays chat (read-only priority)', () => {
const phrases = [
'请总结并生成要点',
'分析一下这份附件然后编写摘要',
'概括文档内容并更新说明',
'查看附件后修改建议列表',
];
for (const text of phrases) {
assert.equal(hasDocumentWriteIntent(text), false, text);
assert.equal(resolveTaskTemplateForMessage(text, bothPool, activeIntake), undefined, text);
}
});
test('pure write intent still routes to task', () => {
assert.equal(hasDocumentWriteIntent('请编制用户初始需求收集表'), true);
});
test('template-only active slot is not pinned; keywords still apply', () => {
assert.equal(isServerRegisteredControlledDoc(templateIntakeSlot), false);
const resolved = resolveTaskTemplateForMessage(
+3 -2
View File
@@ -1,7 +1,7 @@
import { DeliverableItem } from '../types/workbench';
const READ_ONLY_INTENT =
/(?:总结|概括|查看|看看|展示|读取|介绍|解释|分析|评价|检查|查询|当前状态|进度|谁在编辑|是否在编辑)/;
/(?:总结|概括|摘要|要点|简述|解读|查看|看看|展示|读取|介绍|解释|分析|评价|检查|查询|当前状态|进度|谁在编辑|是否在编辑)/;
const WRITE_INTENT =
// 弱写动词(整理/梳理/汇总等)故意不含:走聊天通道,由后端阶段感知 + 确认门禁引导。
/(?:开始|编写|编制|生成|起草|创建|撰写|重写|更新|修改|完善|补充|刷新|重做|改写)/;
@@ -189,9 +189,10 @@ export function wrongWorkflowGuidance(info: NonNullable<ResolvedDocumentTask['wr
return `您提到的文档「${info.docTitle}」属于【${workflowDisplayLabel(info.ownerWorkflow)}】,当前激活工作流为【${workflowDisplayLabel(info.activeWorkflow)}】。已阻止跨工作流操作,请先切换到对应工作流后再操作,或改为点名当前工作流的文档。`;
}
/** 只读动词优先;无明确写动作则不启动文档任务。 */
/** 只读动词优先于写动词:含「总结/分析/查看」等时即使夹杂写动词也不启动文档任务。 */
export function hasDocumentWriteIntent(text: string): boolean {
const normalized = text.trim();
if (!normalized) return false;
if (READ_ONLY_INTENT.test(normalized)) return false;
return WRITE_INTENT.test(normalized);
}
+102
View File
@@ -4,6 +4,7 @@ import {
fetchIntentPlan,
submitIntentPlanDecision,
} from '../api/intentPlans';
import { decideSupplementImpact } from '../api/notifications';
import {
ALL_MOCK_DOCUMENTS,
} from '@demo-seeds';
@@ -72,6 +73,107 @@ export function useCardActions(input: CardActionsInput) {
if (!targetCard) return;
if (targetCard.cardType === 'SUPPLEMENT_IMPACT_CONFIRM') {
const batchId = String(payload?.batch_id || targetCard.supplementBatchId || '');
if (actionId === 'toggle_impact_doc') {
const docId = String(payload?.docId || '');
const selected = !!payload?.selected;
setMessages((prev) =>
prev.map((m) => {
if (m.interactiveCard?.cardId !== cardId || !m.interactiveCard?.impactItems) return m;
return {
...m,
interactiveCard: {
...m.interactiveCard,
impactItems: m.interactiveCard.impactItems.map((it) =>
it.docId === docId ? { ...it, selected } : it
),
},
};
})
);
return;
}
const kind = (payload?.kind || (actionId === 'cancel' || actionId === 'supplement_cancel' ? 'cancel' : 'confirm')) as
| 'confirm'
| 'cancel';
const expectedRevision = Number(payload?.expected_revision || targetCard.planRevision || 0);
const decisionNonce =
String(payload?.decision_nonce || targetCard.decisionNonce || '') ||
(typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
? crypto.randomUUID()
: `${cardId}:${actionId}:${expectedRevision}:${Date.now()}`);
const selectedDocIds =
kind === 'confirm'
? (targetCard.impactItems || [])
.filter((it) => it.selected && !it.busy)
.map((it) => it.docId)
: [];
const decisionDigest =
String(payload?.decision_digest || targetCard.decisionDigest || '') ||
`${kind}:${expectedRevision}:${selectedDocIds.join(',')}`;
setMessages((prev) =>
prev.map((m) =>
m.interactiveCard?.cardId === cardId
? { ...m, interactiveCard: { ...m.interactiveCard!, status: 'executing' } }
: m
)
);
void (async () => {
try {
const batch = await decideSupplementImpact(batchId, {
kind,
selected_doc_ids: selectedDocIds,
decision_nonce: decisionNonce,
decision_digest: decisionDigest,
expected_revision: expectedRevision,
});
setMessages((prev) =>
prev.map((m) => {
if (m.interactiveCard?.cardId !== cardId || !m.interactiveCard) return m;
return {
...m,
interactiveCard: {
...m.interactiveCard,
status: batch.status === 'cancelled' ? 'cancelled' : 'resolved',
planRevision: batch.revision,
decisionNonce: batch.decision_nonce,
resolutionSummary:
kind === 'cancel' ? '已取消补充影响确认' : '补充影响决策已提交',
resolvedBy: currentUser.name,
resolvedAt: new Date().toTimeString().substring(0, 5),
impactItems: (batch.items || []).map((it) => ({
docId: it.doc_id,
title: it.title,
authorClaimed: it.author_claimed_snapshot,
busy: it.busy_snapshot,
changeSummary: it.change_summary,
selected: it.selected,
})),
},
};
})
);
} catch {
setMessages((prev) =>
prev.map((m) =>
m.interactiveCard?.cardId === cardId && m.interactiveCard
? {
...m,
interactiveCard: {
...m.interactiveCard,
status: 'active',
resolutionSummary: '补充影响决策失败,请重试',
},
}
: m
)
);
}
})();
return;
}
if (targetCard.cardType === 'RECHECK_NOTICE' && actionId === 'recheck_no_change') {
const projectId = Number(payload?.project_id || currentProjectId);
const markId = Number(payload?.mark_id);
@@ -49,6 +49,7 @@ export function useDocumentLifecycleActions(input: DocumentLifecycleInput): Docu
...docAST,
lifecycleState: nextState,
isClaimed: nextClaimed,
authorClaimedAt: doc.author_claimed_at || docAST.authorClaimedAt,
authorId: doc.author_id ?? docAST.authorId,
author: doc.author_name || docAST.author,
reviewerId: doc.reviewer_id ?? docAST.reviewerId,
+2 -1
View File
@@ -8,8 +8,9 @@ test('sending a message lives in one hook', () => {
assert.equal((app.match(/useSendMessage\(/g) || []).length, 1);
assert.equal(app.includes('附件服务端注册失败'), false);
assert.equal(hook.includes('registerUploadAttachments'), true);
assert.equal(hook.includes('hasDocumentWriteIntent'), true);
assert.equal(hook.includes('runTaskAttempt'), true);
assert.equal(hook.includes('runStreamAttempt'), true);
assert.equal(hook.includes('失败硬拦'), true);
assert.equal(hook.includes('写意图失败硬拦'), true);
assert.equal(hook.includes('handlePassReview'), true);
});
+68 -62
View File
@@ -11,7 +11,7 @@ import {
isAffirmativeResponse,
} from '../engine/cardActionEngine';
import { markdownToBlockAST } from '../engine/markdownToBlock';
import { resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch';
import { hasDocumentWriteIntent, resolveTaskTemplateForMessage, wrongWorkflowGuidance } from '../engine/templateMatch';
import { createClientTurnId } from '../lib/createClientTurnId';
import { canonicalDocIdForTask } from '../lib/controlledDocHydrate';
import { holdsDocDuty } from '../lib/docGovActionVisibility';
@@ -170,7 +170,7 @@ export function useSendMessage(input: SendMessageInput) {
};
}
// 附件必须先注册为服务端 freeform(server docId);失败硬拦,禁止本地 orphan 回退
// 附件优先注册为 freeform;写意图失败硬拦,只读/对话路径注册失败不阻断发送。
let processedAttachments = attachments;
if (attachments && attachments.length > 0) {
let registered;
@@ -183,74 +183,80 @@ export function useSendMessage(input: SendMessageInput) {
createFreeformDoc,
});
} catch (err) {
return {
accepted: false,
error: err instanceof Error ? err.message : '附件服务端注册失败,请重试',
};
if (hasDocumentWriteIntent(trimmed)) {
return {
accepted: false,
error: err instanceof Error ? err.message : '附件服务端注册失败,请重试',
};
}
console.warn('[upload] freeform register failed; continuing chat/summary', err);
registered = undefined;
}
const newDeliverablesList = registered.map((r) => r.deliverable);
const newDocCacheEntries: Record<string, DocumentAST> = {};
for (const r of registered) {
const baseTitle = r.deliverable.title;
const initialMd = r.detail.content_markdown;
const newAST = markdownToBlockAST(initialMd, {
docId: r.serverDocId,
title: baseTitle,
taskKey: 'user_requirements',
version: 'V1.0',
category: 'private',
creatorId: currentUser.id,
creatorName: currentUser.name,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
});
newAST.governanceMode = 'freeform';
newAST.space = 'my_private';
newDocCacheEntries[r.serverDocId] = newAST;
if (r.attachment.fileRaw) {
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e),
);
// 二进制同步登记 CAS,刷新后可经 raw 端点回退预览
const extMatch = r.attachment.name.match(/\.([^.]+)$/);
const ext = extMatch ? extMatch[1].toLowerCase() : '';
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
if (!TEXT_EXTS.has(ext) && currentProjectId > 0) {
try {
const uploaded = await uploadProjectAsset(
currentProjectId,
r.attachment.fileRaw as Blob,
r.attachment.name,
);
if (r.deliverable.fileMeta) {
r.deliverable.fileMeta.assetId = uploaded.asset_id;
if (registered) {
const newDeliverablesList = registered.map((r) => r.deliverable);
const newDocCacheEntries: Record<string, DocumentAST> = {};
for (const r of registered) {
const baseTitle = r.deliverable.title;
const initialMd = r.detail.content_markdown;
const newAST = markdownToBlockAST(initialMd, {
docId: r.serverDocId,
title: baseTitle,
taskKey: 'user_requirements',
version: 'V1.0',
category: 'private',
creatorId: currentUser.id,
creatorName: currentUser.name,
lifecycleState: 'drafting',
isClaimed: true,
author: currentUser.name,
reviewer: '',
approver: '',
});
newAST.governanceMode = 'freeform';
newAST.space = 'my_private';
newDocCacheEntries[r.serverDocId] = newAST;
if (r.attachment.fileRaw) {
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e),
);
// 二进制同步登记 CAS,刷新后可经 raw 端点回退预览
const extMatch = r.attachment.name.match(/\.([^.]+)$/);
const ext = extMatch ? extMatch[1].toLowerCase() : '';
const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']);
if (!TEXT_EXTS.has(ext) && currentProjectId > 0) {
try {
const uploaded = await uploadProjectAsset(
currentProjectId,
r.attachment.fileRaw as Blob,
r.attachment.name,
);
if (r.deliverable.fileMeta) {
r.deliverable.fileMeta.assetId = uploaded.asset_id;
}
// 回填去重与解析状态,供消息附件卡片展示提示。
r.attachment.assetId = uploaded.asset_id;
r.attachment.deduplicated = uploaded.deduplicated === true;
r.attachment.parseState = uploaded.parse_state;
} catch (e) {
console.warn('[assets] 上传附件到 CAS 失败(预览回退可能不可用):', e);
}
// 回填去重与解析状态,供消息附件卡片展示提示。
r.attachment.assetId = uploaded.asset_id;
r.attachment.deduplicated = uploaded.deduplicated === true;
r.attachment.parseState = uploaded.parse_state;
} catch (e) {
console.warn('[assets] 上传附件到 CAS 失败(预览回退可能不可用):', e);
}
}
}
}
processedAttachments = registered.map((r) => r.attachment);
const newDocIds = new Set(newDeliverablesList.map((d) => d.docId));
setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]);
setDocCache((prev) => ({
...prev,
...newDocCacheEntries,
}));
processedAttachments = registered.map((r) => r.attachment);
const newDocIds = new Set(newDeliverablesList.map((d) => d.docId));
setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]);
setDocCache((prev) => ({
...prev,
...newDocCacheEntries,
}));
if (processedAttachments.length > 0 && processedAttachments[0].docId) {
setActiveDocId(processedAttachments[0].docId);
setActiveCategory('private');
if (processedAttachments.length > 0 && processedAttachments[0].docId) {
setActiveDocId(processedAttachments[0].docId);
setActiveCategory('private');
}
}
}
+16
View File
@@ -9,6 +9,22 @@ test('403 revision denial is not reported as a network error', () => {
assert.match(info.userMessage, /无权访问该文档/);
});
test('403 author-duty denial surfaces draft-vs-submit guidance', () => {
const info = buildNetworkErrorInfo(403, {
errorCode: 'ErrOnlyAuthorCanEdit',
detail: '',
});
assert.equal(info.code, 'ErrOnlyAuthorCanEdit');
assert.match(info.userMessage, /送审需编制人/);
assert.notEqual(info.code, 'NETWORK_ERROR');
});
test('DOC_DUTY_FORBIDDEN keeps draft-vs-submit guidance', () => {
const info = buildNetworkErrorInfo(403, { errorCode: 'DOC_DUTY_FORBIDDEN' });
assert.equal(info.code, 'DOC_DUTY_FORBIDDEN');
assert.match(info.userMessage, /可生成\/编写草稿/);
});
test('TASK_SCOPE_UNRESOLVED names the missing workflow stage', () => {
const info = buildNetworkErrorInfo(409, { errorCode: 'TASK_SCOPE_UNRESOLVED' });
assert.equal(info.code, 'TASK_SCOPE_UNRESOLVED');
+32 -5
View File
@@ -35,6 +35,22 @@ const fatalDenialCopy: Record<string, string> = {
TASK_SCOPE_UNRESOLVED: '无法定位该文档任务的工作流阶段,请检查任务名或联系管理员',
};
/** 编制人身份类错误:可生成/编写草稿,送审仍须编制人。 */
const authorDutyCopy =
'您不是该文档编制人,可生成/编写草稿,但送审需编制人操作';
const authorDutyErrorCodes = new Set([
'DOC_DUTY_FORBIDDEN',
'only_author_can_edit',
'ErrOnlyAuthorCanEdit',
'reviewer_cannot_edit',
'ErrReviewerCannotEdit',
'approver_cannot_edit',
'ErrApproverCannotEdit',
'unauthorized_actor',
'ErrUnauthorizedActor',
]);
/** 任务直通道失败后应降级到聊天确认引导的错误码(仅降级一次,防循环)。 */
export function isTaskStageDegradeCode(code: string | undefined | null): boolean {
return code === 'TASK_SCOPE_UNRESOLVED' || code === 'DOC_TASK_NOT_IN_ACTIVE_STAGE';
@@ -48,7 +64,7 @@ export function buildNetworkErrorInfo(
const detail = opts?.detail || '';
if (code === 'DOC_BUSY' || code === 'document_busy') {
return {
userMessage: detail || '该文档正在被其他用户或项目助手编辑,当前请求未创建新任务',
userMessage: detail || '文档正在修改中(由项目助手或其他用户发起)。您的修改请求未执行,请稍后重试。',
code: 'DOC_BUSY',
class: 'fatal',
actions: [],
@@ -62,16 +78,19 @@ export function buildNetworkErrorInfo(
actions: ['retry'],
};
}
if (code === 'DOC_DUTY_FORBIDDEN') {
if (code === 'DOC_DUTY_FORBIDDEN' || authorDutyErrorCodes.has(code)) {
return {
userMessage: detail || '当前账号不是该文档任务的职责人,无法执行',
code,
userMessage: detail || authorDutyCopy,
code: code || 'DOC_DUTY_FORBIDDEN',
class: 'fatal',
actions: ['retry'],
};
}
if (fatalDenialCopy[code]) {
const retryable = code === 'WORKFLOW_NOT_ACTIVATED' || code === 'DOC_DUTY_NOT_MANAGED' || code === 'TASK_SCOPE_UNRESOLVED';
const retryable =
code === 'WORKFLOW_NOT_ACTIVATED' ||
code === 'DOC_DUTY_NOT_MANAGED' ||
code === 'TASK_SCOPE_UNRESOLVED';
return {
userMessage: detail || fatalDenialCopy[code],
code,
@@ -96,6 +115,14 @@ export function buildNetworkErrorInfo(
actions: ['retry'],
};
}
if (authorDutyErrorCodes.has(code) || /编制|职责|送审|审核|批准/.test(detail)) {
return {
userMessage: detail || authorDutyCopy,
code: code || 'DOC_DUTY_FORBIDDEN',
class: 'fatal',
actions: ['retry'],
};
}
return {
userMessage: detail || '无权执行该操作,请确认账号权限与工作流阶段',
code: code || 'ERR_FORBIDDEN',
+2
View File
@@ -59,6 +59,8 @@ export interface DocumentAST {
sharedAt?: string;
lifecycleState: DocLifecycleState;
isClaimed: boolean;
/** ISO time when current author claimed; optional from server */
authorClaimedAt?: string;
author: string;
authorId?: number;
reviewer: string;
+15 -1
View File
@@ -85,7 +85,8 @@ export type InteractiveCardType =
| 'SLOT_FILLING'
| 'DIFF_PROPOSAL'
| 'REMINDER_NOTICE'
| 'RECHECK_NOTICE';
| 'RECHECK_NOTICE'
| 'SUPPLEMENT_IMPACT_CONFIRM';
export type CardActionStatus =
| 'active'
@@ -124,6 +125,19 @@ export interface InteractiveCardData {
planRevision?: number;
stepCount?: number;
validatedOnly?: boolean;
/** SUPPLEMENT_IMPACT_CONFIRM */
supplementBatchId?: string;
decisionNonce?: string;
decisionDigest?: string;
coverageStatus?: string;
impactItems?: Array<{
docId: string;
title?: string;
authorClaimed?: boolean;
busy?: boolean;
changeSummary?: string;
selected?: boolean;
}>;
}
export type AssistantMessageStatus =