独家
Whose Words <mv-whose-words>
混合来源文本的作者身份披露:AI 辅助草稿、合著文档、引用段落、模板邮件和翻译。
文化出处
《大鼻子情圣》,埃德蒙·罗斯丹(1897 年,戏剧)。一个年轻人用另一个人暗中代写的书信和情话追求他心爱的女人,整部戏都建立在她不知道自己读到的是谁的话之上,直到真相最终揭开,一切都改变了。界面让这种揭示随时可得:每一段文字都记录着它真正出自谁之手(作者本人、AI,是否经过编辑,引文、模板或译文),任何人都可以让文本显示出来。
工作原理
面向混合来源文本的作者身份披露:AI 辅助草稿、共同撰写的文档、引用段落、模板邮件和译文。每个片段都知道自己出自谁之手(现有标记上的 data-origin、spans 数组或实时追踪):作者本人或某位具名合著者、原样接受的 AI 建议、经人编辑的 AI 建议、从来源粘贴的引文、模板样板文字或译文。“Show whose words” 开关为每种来源赋予各自的下划线样式(未编辑的 AI 文本为实线加淡色底纹,编辑过的 AI 文本为虚线,引文为双线,模板为点线,译文为波浪线,合著者为细线),页边汇总以一句话呈现(“62% written by Lena · 30% AI-assisted, 21% of it edited · 8% quoted”),配有带图案的比例条和可在文本中高亮某一来源的图例;悬停或聚焦时的卡片会说明片段来自哪里、何时产生、译文的原文,对于编辑过的 AI 文本,还会给出词级差异以及 “Lena kept 16 of 23 suggested words”。三种视图:reader、badge(紧凑的公开披露,如 “Written with AI assistance · Details”,由占比推导或手动覆盖)和 author;author 会实时追踪 contenteditable 区域或 <textarea>(粘贴的内容成为引文,在 AI 文本内输入会将其变为编辑过的 AI 文本,撤销编辑后又会恢复,在其边缘输入则仍算作者本人的文字,insert() 和 markOrigin() 记录应用插入的内容),并在发布前若草稿中有过多无人编辑的 AI 文本时给出提醒,附带逐条审阅。
| 分类 | 数据展示 |
|---|---|
| 类型 | Web Component(<mv-whose-words>) |
| 状态 | 稳定版 |
| 套件 | 可核查的 AI |
| 同时安装 | button, checkbox, switch |
| Keywords | exclusive, culture, authorship, disclosure, ai, ai-assisted, provenance, attribution, transparency, editor, co-writing, quote, translation, diff, publishing |
When to use
- A publishing tool, CMS or newsletter editor must disclose which parts of an article were suggeste天 by AI and how muc小时 a person edited the分
- A support or sales composer should warn agents before they send a reply that is mostly unedited AI text
- A co-written doc, report or proposal should show who wrote which passage, and what was quoted, templated or translated
- A published page needs a compact AI-assistance badge that readers can expand into a per-passage breakdown
Avoid when
- The question is how much of a document changed across revisions since a signed or approved baseline → use Theseus instead
- Readers need per-passage reliability of generated text rather than who wrote it → use Certainty instead
- The origin of each passage is not actually recorded: guessed or detector-based attributions would mislead readers
安装
node scripts/add.mjs whose-words --out ./src/marvelous使用 Marvelous UI MCP 服务器的 AI 智能体:install_components({ slugs: ["whose-words"], target_dir: "<absolute path>/src/marvelous", framework: "react" })。
复制的文件(含依赖):tokens/tokens.css, core/base.css, components/button/button.css, components/checkbox/checkbox.css, components/switch/switch.css, core/dismiss.js, core/dom.js, core/element.js, core/motion.js, core/observe.js, core/position.js, components/whose-words/whose-words.js, components/whose-words/whose-words.css。
用法
快速开始,最小可运行标记:
<mv-whose-words author="Lena" show>
<p>We cut checkout from 94 to 58 seconds. <span data-origin="ai">Every extra field is a question the customer must answer.</span> <span data-origin="quoted" data-source="Customer interview">“I just want to pay.”</span></p>
</mv-whose-words>标准标记,可在此基础上通过属性、data-* 和 CSS 变量进行定制:
<div id="ww-demo" style="width:min(100%,66rem);margin-inline:auto">
<style>
#ww-demo { display:grid; gap:1.5rem }
#ww-demo .ww-card { padding:1.5rem 1.75rem; border:1px solid var(--mv-border); border-radius:var(--mv-radius-xl); background:var(--mv-surface); box-shadow:var(--mv-shadow-xs) }
#ww-demo .ww-eyebrow { display:flex; align-items:center; gap:.5rem; margin:0 0 1rem; color:var(--mv-fg-muted); font-size:.75rem; font-weight:500 }
#ww-demo article h3 { margin:0 0 .25rem; font-size:1.375rem; letter-spacing:-.015em; line-height:1.25 }
#ww-demo article .ww-by { margin:0 0 1rem; color:var(--mv-fg-muted); font-size:.8125rem }
#ww-demo article p { margin:0 0 .875rem; font-size:.9375rem; line-height:1.7; color:var(--mv-fg) }
#ww-demo article p:last-child { margin-bottom:0 }
#ww-demo article .ww-foot { padding-top:.75rem; border-top:1px dashed var(--mv-border); color:var(--mv-fg-muted); font-size:.8125rem }
#ww-demo .ww-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:1.5rem; align-items:start }
#ww-demo .ww-help h4 { margin:0 0 .5rem; font-size:1rem; letter-spacing:-.01em }
#ww-demo .ww-help article p { margin:.75rem 0 0; font-size:.875rem; line-height:1.65 }
#ww-demo .ww-draft-head { display:flex; justify-content:space-between; gap:.75rem; margin:0 0 1rem; font-size:.8125rem; color:var(--mv-fg-muted) }
#ww-demo .ww-draft-head b { color:var(--mv-fg); font-weight:600 }
#ww-demo .ww-editor { min-height:9rem; padding:.875rem 1rem; border:1px solid var(--mv-input); border-radius:var(--mv-radius-lg); background:var(--mv-bg); font-size:.875rem; line-height:1.65; outline:none }
#ww-demo .ww-editor:focus-visible { box-shadow:var(--mv-focus-ring) }
#ww-demo .ww-editor p { margin:0 0 .625rem } #ww-demo .ww-editor p:last-child { margin:0 }
#ww-demo .ww-actions { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-top:1rem }
#ww-demo .ww-actions .ww-status { margin-left:auto; color:var(--mv-fg-muted); font-size:.75rem }
#ww-demo .ww-hint { margin:0; color:var(--mv-fg-muted); font-size:.75rem; text-align:center }
@media (max-width:56rem) { #ww-demo .ww-row { grid-template-columns:minmax(0,1fr) } }
</style>
<section class="ww-card" aria-label="Published article, reader view">
<p class="ww-eyebrow"><span class="mv-badge" data-variant="secondary" data-shape="pill">Reader view</span> Field notes · Payments</p>
<mv-whose-words id="ww-post" author="Lena Fischer" show>
<article>
<h3>How we cut checkout time by 38%</h3>
<p class="ww-by">Lena Fischer, with Omar Haddad · September 18, 2026</p>
<p>For two quarters, our checkout asked for more than it needed. <span data-origin="ai" data-model="Draft assistant" data-at="2026-09-12T10:04:00">Every extra field is a question the customer has to answer before they can pay.</span> We started by counting them: fourteen, on a phone screen, before the Pay button.</p>
<p>The customer interviews said it more bluntly. <span data-origin="quoted" data-source="Customer interview #14, Aug 28" data-href="#interviews" data-at="2026-09-12T10:11:00">“I just want to pay and get back to my day.”</span> <span data-origin="ai-edited" data-model="Draft assistant" data-at="2026-09-12T10:26:00" data-original="The redesigned flow reduced the median checkout time from 94 seconds to 58 seconds, which represents a significant 38% improvement for our customers.">The new flow brought median checkout time down from 94 to 58 seconds, 38% faster, with no drop in average order value.</span></p>
<p><span data-origin="author" data-by="Omar Haddad" data-at="2026-09-13T16:40:00">On the engineering side, we moved card tokenization ahead of the review step, so the Pay button no longer waits on a network round trip.</span> <span data-origin="translated" data-lang="de" data-model="Machine translation" data-at="2026-09-14T09:02:00" data-original="Die Adressprüfung läuft jetzt im Hintergrund und blockiert den Bezahlknopf nicht mehr.">Address validation now runs in the background and no longer blocks the Pay button.</span></p>
<p>We shipped it to 10% of traffic first and watched refunds and support tickets for a week. <span data-origin="ai" data-model="Draft assistant" data-at="2026-09-12T10:31:00">Next, we will test saved payment methods for returning customers and one-tap wallets in six more markets.</span></p>
<p class="ww-foot"><span data-origin="template" data-source="Release note footer" data-at="2026-09-12T09:58:00">Questions about this release? Reach the Payments team in #payments-help.</span></p>
</article>
</mv-whose-words>
</section>
<div class="ww-row">
<section class="ww-card ww-help" aria-label="Help center article, badge view">
<p class="ww-eyebrow"><span class="mv-badge" data-variant="secondary" data-shape="pill">Badge view</span> Help center</p>
<mv-whose-words id="ww-help" view="badge" author="Priya Raman">
<article>
<h4>Reset two-factor authentication</h4>
<p><span data-origin="ai-edited" data-model="Support assistant" data-at="2026-09-02T14:12:00" data-original="If you lost access to your authenticator app, you can reset two-factor authentication from the sign-in page.">Lost your phone or authenticator app? You can reset two-factor authentication yourself from the sign-in page.</span> Choose <b>Use a recovery code</b>, enter one of the codes you saved when you turned it on, then scan the new QR code.</p>
<p><span data-origin="ai" data-model="Support assistant" data-at="2026-09-02T14:15:00">If you no longer have your recovery codes, an admin of your workspace can reset it for you from Members.</span></p>
</article>
</mv-whose-words>
</section>
<section class="ww-card" aria-label="Reply draft, author view">
<p class="ww-eyebrow"><span class="mv-badge" data-variant="secondary" data-shape="pill">Author view</span> Live tracking</p>
<div class="ww-draft-head"><span>Reply to <b>Kenji Watanabe</b></span><span>Ticket #48213 · Refund request</span></div>
<mv-whose-words id="ww-draft" view="author" author="Sofia Marino" summary="none" show>
<div class="ww-editor" contenteditable="true" role="textbox" aria-multiline="true" aria-label="Reply to Kenji Watanabe">
<p>Hi Kenji,</p>
<p><span data-origin="ai" data-model="Reply assistant" data-at="2026-09-24T09:41:00">Thank you for reaching out, and I'm sorry the order arrived damaged. I've issued a full refund of $86.40 to your original payment method; it should appear within 5 to 7 business days.</span> I've also flagged the carrier so it doesn't happen again.</p>
</div>
</mv-whose-words>
<div class="ww-actions">
<button class="mv-button" data-variant="outline" data-size="sm" type="button" id="ww-suggest">Suggest a closing line</button>
<button class="mv-button" data-size="sm" type="button" id="ww-send">Send reply</button>
<span class="ww-status" id="ww-status" role="status">Type inside the highlighted text to edit it</span>
</div>
</section>
</div>
<p class="ww-hint">Hover or Tab through the underlined passages · open the badge’s Details · click a legend row to highlight one origin</p>
<script type="module">
await customElements.whenDefined("mv-whose-words");
const draft = document.getElementById("ww-draft");
const status = document.getElementById("ww-status");
const closings = [
" Please let me know if there is anything else I can help with.",
" If the refund hasn't arrived by next Friday, just reply to this email and I'll follow up with our payments team.",
];
let n = 0;
document.getElementById("ww-suggest").addEventListener("click", () => {
const last = draft.querySelector(".ww-editor p:last-child");
const r = document.createRange();
r.selectNodeContents(last);
r.collapse(false);
const sel = getSelection();
sel.removeAllRanges();
sel.addRange(r);
draft.insert(closings[n++ % closings.length], "ai", { model: "Reply assistant" });
});
document.getElementById("ww-send").addEventListener("click", () => {
const res = draft.checkBeforePublish();
status.textContent = res.ok ? `Sent · disclosed as “${res.label}”` : `Held: ${Math.round(res.unedited * 100)}% unedited AI text`;
});
draft.addEventListener("mv-origin-change", (e) => {
if (e.detail.source === "edit") status.textContent = e.detail.origin === "ai-edited" ? "Edited: now counted as AI-assisted, edited" : "Edit undone: back to AI, as-is";
});
document.querySelector("#ww-post").addEventListener("click", (e) => { if (e.target.closest("a[href^='#']")) e.preventDefault(); });
</script>
</div>API
Attributes
| Name | 类型 | Default | Description |
|---|---|---|---|
author | string | Name of the primary author. Unmarked text is theirs; it is used in the summary (“written by Lena”), in the badge (“Written by Lena”) and as data-by of spans the component creates. | |
show | boolean | false | Shows whose words: underline patterns in the text, the margin summary (reader view), hover cards and spoken markers. Reflects the switch. |
view | "reader" | "badge" | "author" | reader | reader: switch + disclosure chip, summary in the margin while shown. badge: a compact published disclosure with a Details button that expands the summary and the switch. author: summary always visible, “Readers see: …” preview of the disclosure and the publish nudge. |
summary | "margin" | "top" | "none" | margin | Where the summary goes. margin needs about 680px of width and falls back to top below that; the margin column is kept while hidden, so toggling never reflows the text. |
nudge-threshold | number (0..1) | 0.4 | Author view: share of the text that is unedited AI suggestion above which the nudge shows and checkBeforePublish() returns ok: false. |
paste-origin | origin | "none" | quoted | Live tracking: origin given to pasted text (as plain text). "none" leaves pastes to the browser, counted as the author’s words. |
badge-label | string | Overrides the disclosure text (otherwise “Written by {author}”, “Written with AI assistance” or “Mostly AI-generated, reviewed by {author}” when unedited AI text is 50% or more). | |
label | string | Whose words | Title of the summary (and its group label). |
locale | BCP 47 tag | en-US | Dates, numbers and language names. |
data-origin | "author" | "ai" | "ai-edited" | "quoted" | "template" | "translated" | On any inline element of the text: its origin. Aliases accepted: human, ai-accepted, edited, quote, pasted, translation. Nested marks: the innermost wins. | |
data-by / data-at / data-model | string / ISO date / string | On a mark: the person responsible (author, co-author, who accepted or edited the suggestion), when it was inserted or last edited, and the AI tool or translation engine. | |
data-original | string | On ai-edited: the suggestion as the AI proposed it (the diff is computed against it). On translated: the source text. Set automatically by live tracking. | |
data-source / data-href / data-lang / data-note | string | On quoted: source title and link; on template: template name; on translated: source language (BCP 47, shown as “From German”); any: a free note shown in the card. | |
data-origin-id | string | Groups several elements into one passage (set automatically). A passage split across paragraphs keeps one id, so its diff and counts stay whole. | |
data-whose-words-doc | boolean | Marks the content element when the host has several children (otherwise the only child, or a wrapper created around them). A <textarea> child enables textarea tracking; a contenteditable child enables live tracking. |
Properties
| Name | 类型 | Description |
|---|---|---|
spans | Array<{ text, origin, by?, at?, model?, original?, source?, href?, lang?, note?, id? }> | Get: the whole text as ordered segments (paragraphs separated by blank lines). Set: renders them (blank lines start a new paragraph) or fills the textarea. Can also come from a <script type="application/json" data-spans> child, or be set before the element is defined. |
stats | { total, words, shares: { human, ai, aiEdited, aiAssisted, quoted, template, translated }, editedOfAI, persons: [{ name, share, primary }], level, label, sentence } | Read-only. Shares are 0..1 by letters (whitespace ignored). level: "empty" | "human" | "assisted" | "generated"; label is the disclosure text; sentence is the summary. |
disclosure | { level, label } | Read-only: what the badge says, for use elsewhere (RSS, meta tags, an export). |
strings | object | Overrides any built-in English text (keys as in the source, e.g. { toggle: "Show origins" }). |
author / show / view / summary / nudgeThreshold / pasteOrigin / badgeLabel / label / locale | reflected | Mirror the attributes. |
Methods
| Name | Description |
|---|---|
markOrigin(range, origin, meta?) | Records the origin of text the app inserted or knows about. range: a DOM Range inside the text, { start, end } character offsets (the only form for a textarea), or an element. meta: { by, at, model, original, source, href, lang, note }. AI text marked "ai" is snapshotted, so a later human edit is detected. Returns the first marked element (or the offsets). |
insert(text, origin = "ai", meta?) | Inserts text at the caret (or at the end of the last paragraph) with its origin, e.g. an accepted AI suggestion, and fires an input event so frameworks stay in sync. Returns the new span (or the offsets). |
toggle(force?) | Shows or hides whose words (through the same cancelable event as the switch). Returns false if canceled. |
reviewNext() | Author view: shows whose words and selects the next AI passage nobody edited (the review button calls it). Returns it, or null when none is left. |
checkBeforePublish() | Returns { ok, unedited, level, label }. When not ok, the nudge flashes, focus moves to its review button and the reason is announced: call it from your Publish or Send button. |
refresh() | Re-reads the markup (changes inside the text are already observed). |
Events
| Name | Description |
|---|---|
mv-origin-change | A passage got or changed its origin. detail: { origin, previous, text, source: "api" | "edit" | "paste", target (element or null), range ({ start, end } for a textarea), stats }. Cancelable only for source "paste" (fired before the paste): preventDefault() lets the paste count as the author’s words. |
mv-disclosure-toggle | Cancelable. detail: { part: "text", show, source: "switch" | "api" | "review" | "legend" } when the marks are shown or hidden, or { part: "details", expanded } when the badge’s details open or close. |
mv-nudge | Author view: unedited AI text just crossed nudge-threshold. detail: { unedited, threshold, passages, level }. |
Content structure
| Name | Description |
|---|---|
(content) | One element holding the text (an <article>, a contenteditable <div> or a <textarea>). Marks are data-origin attributes on its inline elements; the component only adds data-origin-id, data-person, data-spot, data-ww-active and, while shown in read mode, tabindex, aria-describedby and a visually hidden marker, all removed when hidden. |
CSS classes
| Name | Description |
|---|---|
mv-whose-words-bar / -toggle / -chip / -details | Header: the switch (input.mv-switch role=switch), the disclosure chip (data-level), and in badge view the Details button. |
mv-whose-words-nudge | Author view notice before publishing; data-flash while checkBeforePublish() draws attention to it. |
mv-whose-words-aside / -sentence / -meter / -seg / -legend / -key / -swatch | Summary: sentence, patterned meter (data-key per origin, data-person per co-author) and legend buttons (aria-pressed) that highlight one origin. |
mv-whose-words-card | Hover, focus or caret card (top layer). data-pinned after a click or Enter: links become clickable and it closes with Escape. |
mv-whose-words-field / -mirror | Textarea tracking: the wrapper and the aligned mirror that draws the marks under the textarea’s own text. |
CSS variables
| Name | Default | Description |
|---|---|---|
--mv-whose-words-ai | violet (light-dark) | AI suggestions, as-is and edited. |
--mv-whose-words-quoted / -template / -translated | blue / gray / green | Other origins. |
--mv-whose-words-author / --mv-whose-words-person-2…5 | neutral / amber, pink, teal, olive | Primary author (meter) and co-authors. |
--mv-whose-words-margin | 15.5rem | Width of the margin column. |
--mv-whose-words-offset | 0.26em | Underline offset of the marks. |
Accessibility
Nothing is added to the reading experience until someone asks: with the marks hidden, the text is untouched for screen readers. The toggle is a native checkbox with role="switch" and a visible label; showing or hiding announces the summary sentence in a polite live region. The summary is always real text (“62% written by Lena · 30% AI-assisted, 21% of it edited · 8% quoted”), in a group labeled by its title; the meter is decorative and aria-hidden, and the legend is a list of toggle buttons (aria-pressed) whose labels carry the same underline pattern as the text. Origins never rely on color: each has its own line style (solid with a wash, dashed, double, dotted, wavy, thin), repeated in the meter as fill patterns. While shown in read mode, every marked passage gets a visually hidden start marker (“AI suggestion, edited:”) so continuous reading hears where each origin begins, becomes a tab stop and is described (aria-describedby) by a full sentence: origin, who, when, how many suggested words were kept, the original suggestion, source or source-language text. Focus or hover opens the card; Enter, Space or a click pins it as a labeled dialog with focus moved inside (Escape closes it and returns focus); all of this is removed when hidden. In live editing (contenteditable or textarea) passages are not tab stops: moving the caret into one opens the card and announces its origin once, and the review button selects each unedited AI passage in turn. The badge’s Details button is a disclosure (aria-expanded, aria-controls). Reduced motion (OS or data-motion="reduce"): no card, panel or nudge animation. Forced colors: marks use CanvasText (Highlight for unedited AI text), meter patterns are redrawn in system colors, borders use CanvasText. Known limits: origins are only as good as what the app records (nothing is detected or guessed); the word-level diff splits on spaces, so languages written without spaces (Chinese, Japanese, Thai) compare whole runs, and it is skipped for very long passages (over about 90,000 word pairs); live tracking sees only edits made through the browser (beforeinput and paste), so text replaced by script without insert() or markOrigin() counts as the author’s own.