feat: KOOK自动获取bot身份 + action/narration剥出气泡 + 渠道配置表单回填
CI / Backend (build / vet / test / cross-compile) (push) Has been cancelled
CI / Frontend (tsc + vite build) (push) Has been cancelled

agent-116 KOOK token即用+自动获取:
- kook/identity.go: Connect 调 GET /user/me 拿 bot 身份(bot_id/bot_username/bot_nickname), 失败容错
- 渠道列表 enrichAccountsWithBot 附 bot 身份; base_url空用默认/receive_mode默认ws(只填token可跑)

agent-117 action/narration 剥出气泡(主人需求: 动作/旁白不进气泡,按位置上/下):
- parseContent.splitBubble(content)->{body,head,tail}: body=纯文本(去标签), head=开头/中间action/narr(气泡上), tail=结尾(气泡下)
- renderContent 只渲染text(去内联灰斜体); chat.tsx BubbleNotes 泡外 *动作/旁白* 灰斜体小字(气泡外依附), SplitChatBubble 先剥splitBubble再splitParagraphs(body), <p>/\n\n/毛玻璃/SourceLabel不破坏
- 注: 非chat mode renderContent 现在丢弃action/narr(可接受)

Channels 配置表单回填(修save后输入框空):
- openConfig 从 data.accounts[0].config 回填 token(掩码提示)/base_url/平台字段/黑白名单/限流, 不再硬清空
全仓 build/vet/test绿+前端build绿
This commit is contained in:
Aska committed 2026-09-07 15:31:14 +08:00
1 parent bdd9d752e1
commit 65091f6497
15 files changed
+571 -83

No files matched your search

+62 -25
View File
@@ -7,6 +7,7 @@ import { Icon, type IconName } from "./Icon";
import { WakePath } from "./status";
import { modeBody } from "./messages";
import { renderContent } from "../lib/renderContent";
import { splitBubble, type ContentPart } from "../lib/parseContent";
/** Collapsible inline chain-of-thought block. A Miuix disclosure row: icon chip
* + label, spinner when still thinking, and a single-line muted preview of the
@@ -162,22 +163,24 @@ export function MessageBubble({
return <AssistantBubble message={message} mode={mode} active={active} />;
}
/** Split a chat reply into its `<p>`-delimited paragraph bodies. Each complete
* `<p>…</p>` block becomes one bubble segment; a reply that carries no `<p>`
* wrapper yields an empty list so the caller renders one bubble as before.
/** Split a chat reply's annotation-stripped body into `<p>`-delimited paragraph
* bodies. Each complete `<p>…</p>` block becomes one bubble segment; a body that
* carries no `<p>` wrapper falls back to splitting on `\n\n` runs, and a body
* with neither yields an empty list so the caller handles the empty reply.
* Non-greedy + multi-line so a tag may span newlines; a still-streaming,
* unclosed `<p>` simply does not match and stays out until it closes. */
function splitParagraphs(content: string): string[] {
// Preferred: the agent wraps each paragraph in <p>…</p> (design/07 §2). When it
// emits plain \n\n runs instead (the model often does), split on those too so
// each logical paragraph becomes its own bubble. But never cut inside an
// <action>…</action> / <narration>…</narration> block, and never split on a
// single \n (that is an in-line break, kept inside the paragraph).
// each logical paragraph becomes its own bubble. `content` here is the
// annotation-stripped body from `splitBubble`, so no <action>/<narration> block
// can be cut across; never split on a single \n (that is an in-line break, kept
// inside the paragraph).
const pRe = /<p[^>]*>([\s\S]*?)<\/p>/g;
const pSegments = [...content.matchAll(pRe)].map((m) => m[1].trim());
if (pSegments.length > 0) return pSegments;
// Keep non-<p> content (action/narration + body) so we don't strip the tags.
// Keep non-<p> remainder as its own paragraph bodies.
const segments: string[] = [];
for (const part of content.split(/\n\s*\n/)) {
if (!part.trim()) continue;
@@ -186,8 +189,37 @@ function splitParagraphs(content: string): string[] {
return segments;
}
/** Chat-mode body: a Miuix surface shell around a whitespace-preserving span,
* with `<action>`/`<narration>` rendered as muted italic via renderContent. */
/** Small muted attachment note for a single `<action>` / `<narration>` block.
* Rendered as a grey italic micro-line OUTSIDE the bubble (anchored to the
* message's head/tail by `splitBubble`) — never inside the bubble body.
* `align` mirrors the bubble column: `left` indents under the assistant avatar
* column, `right` lines the note up with a right-aligned user bubble. */
function BubbleNotes({
items,
align = "left",
}: {
items: ContentPart[];
align?: "left" | "right";
}): React.ReactNode {
if (items.length === 0) return null;
const container =
align === "right"
? "flex flex-col items-end gap-0.5"
: "flex flex-col items-start gap-0.5 pl-10";
return (
<div className={container}>
{items.map((item, i) => (
<div key={i} className="text-[10px] italic leading-tight text-[var(--mu-text-lo)]">
*{item.type === "action" ? "动作" : "旁白"}:{item.text}*
</div>
))}
</div>
);
}
/** Chat-mode body: a Miuix surface shell around a whitespace-preserving span.
* `text` is already the annotation-stripped body paragraph from `splitBubble`,
* so it renders as plain text only — action/narration notes live outside. */
function ChatBody({ text, streaming }: { text: string; streaming?: boolean }): React.ReactNode {
return (
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)] shadow-[var(--mu-shadow-1)]">
@@ -267,13 +299,14 @@ function UserParaBubble({
);
}
/** Chat-only entry: a `chat`-mode reply's `<p>…</p>` paragraphs are split into
* stacked bubbles (first = full bubble, remainder = compact). Any other mode —
* code / home / work / docs / vision / voice / agents / settings — is passed
* straight through to MessageBubble so its body renders untouched: no `<p>`
* splitting, and its content (which may itself contain `<p>` / `<action>` /
* `<narration>` as literal text or code) is never re-parsed. A `chat` message
* with no `<p>` wrapper renders as one bubble exactly as before. */
/** Chat-only entry: a `chat`-mode reply's `<action>`/`<narration>` blocks are
* peeled off by `splitBubble` into head/tail attachment notes, and the remaining
* plain-text body is split into stacked bubbles (first = full bubble, remainder
* = compact). Any other mode — code / home / work / docs / vision / voice /
* agents / settings — is passed straight through to MessageBubble so its body
* renders untouched: no `<p>` splitting, and its content (which may itself
* contain `<p>` / `<action>` / `<narration>` as literal text or code) is never
* re-parsed. */
export function SplitChatBubble({
message,
active = false,
@@ -284,20 +317,23 @@ export function SplitChatBubble({
const mode = message.mode ?? "chat";
if (mode !== "chat") return <MessageBubble message={message} active={active} />;
const segments = splitParagraphs(message.content ?? "");
if (segments.length === 0) {
// No complete `<p>…</p>` pair: render the single bubble as-is (but strip a
// lone wrapper tag so a still-streaming reply never flashes raw `<p>`).
const raw = message.content ?? "";
const clean = raw.replace(/<p[^>]*>/g, "").replace(/<\/p>/g, "");
const bubble = clean === raw ? message : { ...message, content: clean };
return <MessageBubble message={bubble} active={active} />;
const { body, head, tail } = splitBubble(message.content ?? "");
const segments = splitParagraphs(body);
if (segments.length === 0 && head.length === 0 && tail.length === 0) {
// No visible content: hand off so the empty / still-streaming reply keeps its
// usual chrome (avatar, mood, "生成中…").
return <MessageBubble message={message} active={active} />;
}
// Group the paragraphs of one reply into a tighter column so intra-reply
// segments read as one bubble while separate replies keep the 16px list gap.
// The annotation notes (head/tail) attach to the whole group — above the first
// bubble and below the last — never inside a bubble's body.
const align = message.role === "user" ? "right" : "left";
return (
<div className="flex flex-col gap-2.5">
<div className={`flex flex-col gap-2.5 ${message.role === "user" ? "items-end" : ""}`}>
<BubbleNotes items={head} align={align} />
{segments.map((seg, i) =>
message.role === "user" ? (
<UserParaBubble key={i} message={message} text={seg} full={i === 0} />
@@ -311,6 +347,7 @@ export function SplitChatBubble({
/>
),
)}
<BubbleNotes items={tail} align={align} />
</div>
);
}
@@ -15,7 +15,7 @@ import { renderContent } from "../../lib/renderContent";
function shell(children: ReactNode): ReactNode {
return (
<div className="rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">
{children}
</div>
);
@@ -116,7 +116,7 @@ export function VisionMessageBody({ message }: { message: ChatMessage }): ReactN
const hasImage = Boolean(message.image);
if (!message.content && !message.streaming && !hasImage) return null;
return (
<div className="overflow-hidden rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)]">
<div className="liquid-glass-soft overflow-hidden rounded-[var(--mu-radius-lg)]">
{hasImage ? (
<img
src={message.image as string}
@@ -154,7 +154,7 @@ export function VoiceMessageBody({ message }: { message: ChatMessage }): ReactNo
}, [message.streaming]);
return (
<div className="rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-3">
<div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-3">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-[var(--mu-accent)]/15 text-[var(--mu-accent)]">
<Icon name="mic" size={20} />
@@ -229,6 +229,6 @@ export function modeBody(mode: Mode, message: ChatMessage): ReactNode {
case "voice": return <VoiceMessageBody message={message} />;
case "agents": return <AgentsMessageBody message={message} />;
case "settings": return <SettingsMessageBody message={message} />;
default: return <div className="rounded-[var(--mu-radius-lg)] border border-[var(--mu-glass-border)] bg-[var(--mu-surface-3)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">{fallbackText(message)}</div>;
default: return <div className="liquid-glass-soft rounded-[var(--mu-radius-lg)] px-4 py-2.5 text-sm text-[var(--mu-text-hi)]">{fallbackText(message)}</div>;
}
}