Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion chatbot/src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import {
elapsedSeconds,
formatElapsed,
confirmationCaption,
isProposalTimeout,
canChatInProject,
projectPickerOptions,
} from "./api";
Expand Down Expand Up @@ -1459,7 +1460,7 @@ export default function App() {
<div className="loading-session">正在恢复对话…</div>
) : null}
{messages.map((message, index) =>
confirmationCaption(message, messages[index - 1]) ? (
isProposalTimeout(message, messages[index - 1]) ? null : confirmationCaption(message, messages[index - 1]) ? (
<div
key={index}
className="confirmation-note"
Expand Down Expand Up @@ -1579,6 +1580,12 @@ export default function App() {
artifact={a}
context={operationContext}
onApply={chat.readOnly ? null : chat.applyDraft}
onDecide={
chat.readOnly
? null
: (messageIndex, draftIndex, decision) =>
chat.decideProposal(messageIndex, draftIndex, decision, approval)
}
onPreview={openArtifact}
/>
))}
Expand Down
104 changes: 100 additions & 4 deletions chatbot/src/SidePanel.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,57 @@ const ExternalLink = (props) => (
<HarnessIcon name="preview-expand" {...props} />
);

export function DraftCard({ artifact, onApply, onPreview, context = {} }) {
function useCountdown(expiresAt) {
const deadline = expiresAt ? Date.parse(expiresAt) : NaN;
const [now, setNow] = useState(() => Date.now());
useEffect(() => {
if (Number.isNaN(deadline)) return undefined;
setNow(Date.now());
const timer = setInterval(() => setNow(Date.now()), 1000);
return () => clearInterval(timer);
}, [deadline]);
if (Number.isNaN(deadline)) return null;
return Math.max(0, Math.ceil((deadline - now) / 1000));
}

function formatCountdown(seconds) {
return `${Math.floor(seconds / 60)}:${String(seconds % 60).padStart(2, "0")}`;
}

export function DraftCard({
artifact,
onApply,
onDecide,
onPreview,
context = {},
}) {
const [busy, setBusy] = useState(false),
[uncertain, setUncertain] = useState(false),
[error, setError] = useState("");
const draft = artifact.draft;
const declined = ["declined", "muted", "expired"].includes(draft.decision);
const answerable =
!!draft.proposal &&
!draft.inherited &&
!draft.saved &&
!draft.decision &&
!!onDecide;
const remaining = useCountdown(
answerable && draft.proposal.timing === "start"
? draft.proposal.expiresAt
: "",
);
const decide = async (decision) => {
setBusy(true);
setError("");
try {
await onDecide(artifact.messageIndex, artifact.draftIndex, decision);
} catch (e) {
setError(operationError(e));
} finally {
setBusy(false);
}
};
const [repositories, setRepositories] = useState([]),
[selectedRepositories, setSelectedRepositories] = useState(null),
[repositoryError, setRepositoryError] = useState(""),
Expand Down Expand Up @@ -114,18 +160,41 @@ export function DraftCard({ artifact, onApply, onPreview, context = {} }) {
{artifact.name !== actionName(draft.tool)
? `${actionName(draft.tool)} · `
: ""}
{draft.saved
{declined
? draft.decision === "muted"
? "未采纳,本会话不再建议"
: draft.decision === "expired"
? "已超时"
: "未采纳"
: draft.saved
? "已执行"
: draft.resolved
? "已完成"
: draft.inherited
? "继承的历史记录"
: "待确认"}
: draft.proposal
? "AI 助手的建议"
: "待确认"}
</small>
</span>
{target ? <ExternalLink size={16} /> : <ChevronRight size={16} />}
</Title>
<div className="operation-summary">
{draft.proposal?.reason ? (
<p className="proposal-reason">
{draft.proposal.timing !== "start"
? "可选的后续工作"
: "建议整件事走 SOP"}
:{draft.proposal.reason}
</p>
) : null}
{remaining !== null ? (
<p className="proposal-countdown" role="timer">
{remaining > 0
? `${formatCountdown(remaining)} 后未确认,AI 助手将直接执行`
: "已超时,AI 助手即将直接执行"}
</p>
) : null}
{draft.review ? (
<p className="muted">帮我批准 · {draft.review.model || "AI 审查"}:{draft.review.rationale}</p>
) : null}
Expand Down Expand Up @@ -207,7 +276,12 @@ export function DraftCard({ artifact, onApply, onPreview, context = {} }) {
</p>
))}
{error ? <p className="error">{error}</p> : null}
{!draft.inherited && !target && onApply ? (
{draft.decision === "expired" && !draft.inherited ? (
<button type="button" className="quiet-button" disabled>
已超时,AI 助手直接执行
</button>
) : null}
{!draft.inherited && !target && onApply && !declined ? (
<button
className={draft.saved ? "quiet-button" : "primary-button"}
disabled={
Expand Down Expand Up @@ -243,11 +317,33 @@ export function DraftCard({ artifact, onApply, onPreview, context = {} }) {
</>
) : busy ? (
"执行中…"
) : draft.proposal ? (
draft.tool === "issues_save" ? "建需求并走 SOP" : "走 SOP"
) : (
`确认${summary.title}`
)}
</button>
) : null}
{answerable ? (
<div className="proposal-actions">
<button
type="button"
className="quiet-button"
disabled={busy}
onClick={() => decide("declined")}
>
{draft.proposal.timing === "start" ? "让 AI 助手自己做" : "不需要"}
</button>
<button
type="button"
className="proposal-mute"
disabled={busy}
onClick={() => decide("muted")}
>
本会话不再建议
</button>
</div>
) : null}
{savedIssue?.id &&
context.onLinkIssue &&
!context.runId &&
Expand Down
2 changes: 1 addition & 1 deletion chatbot/src/SubagentSession.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,7 +92,7 @@ export function SubagentSession({ agent, live = false, artifacts = [], onOpenArt
className="node-message node-message-user"
aria-label="收到的任务"
>
<span className="node-message-sender">Main Bot</span>
<span className="node-message-sender">AI 助手</span>
<RichContent message={step.input || step.detail} />
</article>
) : null}
Expand Down
23 changes: 23 additions & 0 deletions chatbot/src/api.js
Original file line number Diff line number Diff line change
Expand Up @@ -390,8 +390,31 @@ export function restoreStream(snapshot) {
// Keep the confirmation acknowledgement visible without presenting an internal
// model-resume instruction as something the user typed. Validate the preceding
// saved receipt; ordinary user text must never be hidden by a prefix match.
// Mirrors assistantProposalDeclinedText and assistantProposalExpiredText.
const PROPOSAL_DECLINED = "不走 SOP,这件事由你直接完成。";
const PROPOSAL_EXPIRED = "SOP 建议超时未确认,这件事由你直接完成。";
// The card already says it timed out, so the turn that hands the work back
// is not shown.
export function isProposalTimeout(message, previous) {
return (
message?.role === "user" &&
message.trigger === "proposal_timeout" &&
message.content === PROPOSAL_EXPIRED &&
previous?.role === "assistant" &&
(parseReply(previous).drafts || []).some(
(d) => d.proposal && d.decision === "expired",
)
);
}

export function confirmationCaption(message, previous) {
if (message?.role !== "user" || previous?.role !== "assistant") return "";
if (message.content === PROPOSAL_DECLINED)
return (parseReply(previous).drafts || []).some(
(d) => d.proposal && ["declined", "muted"].includes(d.decision),
)
? "不走 SOP,让 AI 助手自己做"
: "";
const match =
/^已确认并执行 ([a-z_]+)。请核对这条草稿的保存回执,继续完成原始请求;不要重复刚才的写入,下一项需要确认时提交草稿,全部完成时总结实际结果。$/.exec(
message.content || "",
Expand Down
24 changes: 23 additions & 1 deletion chatbot/src/styles.css
Original file line number Diff line number Diff line change
Expand Up @@ -2303,11 +2303,33 @@ a.project-entry {
font-size: 10px;
}
.draft-card > .primary-button,
.draft-card > .quiet-button {
.draft-card > .quiet-button,
.proposal-actions > .quiet-button {
min-height: 29px;
padding: 5px 9px;
font-size: 11px;
}
.proposal-reason {
color: var(--muted);
}
.proposal-countdown {
color: var(--accent);
font-variant-numeric: tabular-nums;
}
.proposal-actions {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: 8px;
}
.proposal-mute {
font-size: 11px;
color: var(--muted);
padding: 4px 2px;
}
.proposal-mute:hover {
color: var(--text);
}
.run-row {
border: 1px solid var(--line);
border-radius: 9px;
Expand Down
65 changes: 60 additions & 5 deletions chatbot/src/useChat.js
Original file line number Diff line number Diff line change
Expand Up @@ -924,18 +924,42 @@ export function useChat() {
applyingRef.current.add(key);
let executed = false;
try {
const stored = await api(sessionPath(id));
const reply = JSON.parse(stored.messages[messageIndex].content);
const draft = reply.drafts[draftIndex];
let stored = await api(sessionPath(id));
let reply = JSON.parse(stored.messages[messageIndex].content);
let draft = reply.drafts[draftIndex];
if (draft.saved || draft.inherited) return null;
if (generation?.status === "running")
throw Error("请等待回复结束后再确认操作。");
if (draft.proposal && !draft.decision) {
// Accept before running it: once the timeout has handed the work to
// the assistant, the accept fails and the operation must not run.
try {
stored = (
await write(`${sessionPath(id)}/proposals`, {
messageIndex,
draftIndex,
decision: "accepted",
})
).session;
} catch (error) {
void api(sessionPath(id))
.then((latest) => currentId.current === id && setSession(latest))
.catch(() => {});
throw error;
}
reply = JSON.parse(stored.messages[messageIndex].content);
draft = reply.drafts[draftIndex];
}
if (draft.proposal && draft.decision !== "accepted") {
if (currentId.current === id) setSession(stored);
throw Error("这条建议已经处理过,不能执行。");
}
try {
Object.assign(draft, reviewedOperation(draft, review));
} catch (error) {
if (error.draftChanged && currentId.current === id) setSession(stored);
throw error;
}
if (generation?.status === "running")
throw Error("请等待回复结束后再确认操作。");
// The session header lets a confirmed SOP continue in this
// conversation's workspace.
const result = await api("/mcp", {
Expand Down Expand Up @@ -1002,6 +1026,36 @@ export function useChat() {
applyingRef.current.delete(key);
}
}
// Answers an assistant proposal card. Declining work the assistant had not
// started continues with the turn the server returns.
async function decideProposal(messageIndex, draftIndex, decision, approvalMode = "confirm") {
const id = activeId;
if (generation?.status === "running")
throw Error("请等待回复结束后再处理建议。");
const key = `${id}:${messageIndex}:${draftIndex}`;
if (applyingRef.current.has(key)) throw Error("这条建议正在处理,请稍候。");
applyingRef.current.add(key);
try {
const result = await write(`${sessionPath(id)}/proposals`, {
messageIndex,
draftIndex,
decision,
});
if (currentId.current !== id) return;
setSession(result.session);
if (result.followUp)
await send({
text: result.followUp.text,
messagesOverride: result.session.messages,
approvalMode,
});
} catch (e) {
setError(e.message);
throw e;
} finally {
applyingRef.current.delete(key);
}
}
async function linkIssue(issueId) {
if (submittingRef.current || generation?.status === "running") {
setError("请等待回复结束或停止生成,再关联需求。");
Expand Down Expand Up @@ -1130,6 +1184,7 @@ export function useChat() {
edit,
regenerate,
applyDraft,
decideProposal,
refresh,
refreshSessions,
};
Expand Down
Loading
Loading