Skip to content
Merged
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
17 changes: 17 additions & 0 deletions catalog/catalog.json
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,23 @@
"voice"
],
"versions": [
{
"version": "0.1.0-alpha.20",
"contractVersion": "0.1.0",
"manifestPath": "plugin.yaml",
"artifact": {
"kind": "npm",
"packageName": "@clowder-ai/companion",
"version": "0.1.0-alpha.20",
"tarballUrl": "https://registry.npmjs.org/@clowder-ai/companion/-/companion-0.1.0-alpha.20.tgz",
"integrity": "sha512-R7KbX0EZAvE3Ll4S7d9lSrk5zIkBe8jnIHpdh82lE7v+mqoXtkDUifbXCYuEdMoDcEgkq/r49M9VBHnx9vtk6A==",
"shasum": "ac97cb246ab15f64ecb8404a73eeb3e58b59fc3d",
"provenance": {
"repository": "https://github.com/zts212653/clowder-ai-plugins",
"sourceDirectory": "packages/companion"
}
}
},
{
"version": "0.1.0-alpha.19",
"contractVersion": "0.1.0",
Expand Down
2 changes: 1 addition & 1 deletion packages/companion/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@clowder-ai/companion",
"version": "0.1.0-alpha.19",
"version": "0.1.0-alpha.20",
"description": "The desktop voice body for your Clowder companion",
"license": "MIT",
"type": "module",
Expand Down
24 changes: 23 additions & 1 deletion packages/companion/src/recent-bubble.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,29 @@ export class RecentBubble {
for (const [index, row] of this.rows.entries()) {
const entry = entries[index];
row.hidden = !entry;
row.textContent = entry ? `${String(entry.name).slice(0, 28)}:${String(entry.text).slice(-120)}` : '';
const speaker = entry ? `${String(entry.name).slice(0, 28)}:` : '';
const message = entry ? String(entry.text).slice(-120) : '';
row.textContent = `${speaker}${message}`;
if (entry && row.ownerDocument?.createElement && typeof row.replaceChildren === 'function') {
const speakerNode = row.ownerDocument.createElement('span');
const messageNode = row.ownerDocument.createElement('span');
const messageText = row.ownerDocument.createElement('bdi');
speakerNode.className = 'recent-speaker';
messageNode.className = 'recent-message';
messageText.className = 'recent-message-text';
messageText.dir = 'auto';
speakerNode.textContent = speaker;
// dir=auto resolves the first strong character. Reversing code points for
// this hidden-in-the-same-tick probe therefore resolves the latest strong
// character, which is the edge this compact tail projection must retain.
messageText.textContent = Array.from(message).reverse().join('');
messageNode.append(messageText);
row.replaceChildren(speakerNode, messageNode);
const direction = row.ownerDocument.defaultView?.getComputedStyle(messageText).direction;
messageText.dir = direction === 'rtl' ? 'rtl' : 'ltr';
messageText.textContent = message;
messageNode.dataset.direction = direction === 'rtl' ? 'rtl' : 'ltr';
}
row.dataset ??= {};
row.dataset.role = entry?.role ?? '';
}
Expand Down
8 changes: 6 additions & 2 deletions packages/companion/src/style.css
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ button:disabled { opacity: .45; cursor: default; }
.floating { position: absolute; z-index: 3; border-radius: 12px; border: 1px solid var(--desktop-line-strong); background: var(--desktop-paper); box-shadow: 0 14px 28px #18171526, 0 2px 5px #1817150f; }
.floating { max-height: 500px; overflow: auto; }
#bubble { width: 240px; max-height: 80px; overflow: hidden; padding: 8px 11px; pointer-events: none; }
#bubble p { margin: 0; font-size: 11px; line-height: 1.45; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
#bubble p { margin: 0; display: flex; min-width: 0; font-size: 11px; line-height: 1.45; overflow: hidden; white-space: nowrap; }
#bubble p + p { margin-top: 4px; }
#bubble [data-role="user"] { color: var(--cafe-text-secondary); }
.floating button { border: 0; background: transparent; border-radius: 7px; padding: 8px 11px; font-weight: 400; }
Expand Down Expand Up @@ -101,7 +101,11 @@ button:disabled { opacity: .45; cursor: default; }
.call-share-context small, .call-share-line small { color: var(--desktop-muted); font-size: 11px; }
.call-recent { margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--desktop-line); }
.call-recent-label { color: var(--desktop-muted); font-size: 11px; letter-spacing: .05em; }
.call-recent p { margin: 4px 0 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-size: 12px; line-height: 1.45; }
.call-recent p { margin: 4px 0 0; display: flex; min-width: 0; overflow: hidden; white-space: nowrap; font-size: 12px; line-height: 1.45; }
.recent-speaker { flex: none; }
.recent-message { min-width: 0; flex: 1 1 auto; overflow: hidden; direction: rtl; text-align: left; text-overflow: ellipsis; white-space: nowrap; }
.recent-message[data-direction="rtl"] { direction: ltr; }
.recent-message-text { unicode-bidi: isolate; }
.call-recent p[data-role="user"] { color: var(--desktop-muted); }
.call-actions { justify-content: flex-end; }
.call-actions #end { min-width: 88px; margin-left: auto; }
Expand Down
166 changes: 166 additions & 0 deletions packages/companion/test/panel-layout.browser.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -77,6 +77,47 @@ async function visibleClipping(page, panelId) {
}, panelId);
}

async function textRangeVisibility(page, rowSelector, needle) {
return page.evaluate(({ rowSelector, needle }) => {
const row = document.querySelector(rowSelector);
const walker = document.createTreeWalker(row, NodeFilter.SHOW_TEXT);
let node;
while ((node = walker.nextNode())) {
const index = node.data.indexOf(needle);
if (index === -1) continue;
const range = document.createRange();
range.setStart(node, index);
range.setEnd(node, index + needle.length);
const bounds = row.getBoundingClientRect();
const text = range.getBoundingClientRect();
const hit = document.elementFromPoint((text.left + text.right) / 2, (text.top + text.bottom) / 2);
return {
rowText: row.textContent,
visible: text.left >= bounds.left - 0.5 && text.right <= bounds.right + 0.5
&& text.top >= bounds.top - 0.5 && text.bottom <= bounds.bottom + 0.5
&& hit !== null && row.contains(hit),
bounds: { left: bounds.left, right: bounds.right, top: bounds.top, bottom: bounds.bottom },
text: { left: text.left, right: text.right, top: text.top, bottom: text.bottom },
};
}
return { rowText: row.textContent, visible: false, missing: true };
}, { rowSelector, needle });
}

async function recentRowPresentation(page, rowSelector) {
return page.evaluate(rowSelector => {
const row = document.querySelector(rowSelector);
const speaker = row.querySelector('.recent-speaker').getBoundingClientRect();
const message = row.querySelector('.recent-message');
const text = row.querySelector('.recent-message-text').getBoundingClientRect();
return {
gap: text.left - speaker.right,
textOverflow: getComputedStyle(message).textOverflow,
textDirection: message.dataset.direction,
};
}, rowSelector);
}

test('dynamic call content and expanded transcript keep every action visible', async () => {
const page = await openPreview();
try {
Expand Down Expand Up @@ -143,6 +184,131 @@ test('subtitle return is visible and restores the same shared call at compact an
}
});

test('the narrow call bar keeps both speaker labels and the newest streamed tail visibly on screen', async () => {
const page = await openPreview({ width: 390, height: 620 });
try {
await page.click('#pet');
await page.click('#begin');
await page.waitForFunction(() => document.getElementById('call-state')?.textContent === '通话中');
await page.evaluate(() => {
const callId = '11111111-1111-4111-8111-111111111111';
window.__emitAudio({ kind: 'audio', type: 'transcript', callId, role: 'user', itemId: 'long-user',
text: `${'这是一段很长的用户实时输入'.repeat(18)}用户最新尾部。` });
window.__emitAudio({ kind: 'audio', type: 'transcript', callId, role: 'assistant', itemId: 'long-assistant',
text: `${'这是猫猫正在持续回答的长句'.repeat(18)}assistant tail!` });
});

const userLabel = await textRangeVisibility(page, '.call-recent p[data-role="user"]', '语音:');
const userTail = await textRangeVisibility(page, '.call-recent p[data-role="user"]', '用户最新尾部。');
const assistantLabel = await textRangeVisibility(page, '.call-recent p[data-role="assistant"]', '宪宪:');
const assistantTail = await textRangeVisibility(page, '.call-recent p[data-role="assistant"]', 'assistant tail!');
assert.equal(userLabel.visible, true, JSON.stringify(userLabel));
assert.equal(userTail.visible, true, JSON.stringify(userTail));
assert.equal(assistantLabel.visible, true, JSON.stringify(assistantLabel));
assert.equal(assistantTail.visible, true, JSON.stringify(assistantTail));

await page.evaluate(async () => {
const { RecentBubble } = await import('/recent-bubble.mjs');
const bubble = new RecentBubble([
document.getElementById('call-bubble-first'), document.getElementById('call-bubble-second'),
]);
bubble.append('user', 'hi', '语音');
bubble.append('assistant', '好的。', '宪宪');
});
for (const role of ['user', 'assistant']) {
const presentation = await recentRowPresentation(page, `.call-recent p[data-role="${role}"]`);
assert(Math.abs(presentation.gap) <= 0.5, JSON.stringify(presentation));
assert.equal(presentation.textOverflow, 'ellipsis');
assert.equal(presentation.textDirection, 'ltr');
}

const callbar = await visibleClipping(page, 'actions');
assert(callbar.height <= 500);
assert.deepEqual(callbar.clippedButtons, []);
} finally {
await page.close();
}
});

test('the narrow call bar keeps the logical tail visible for right-to-left messages', async () => {
const page = await openPreview({ width: 390, height: 620 });
try {
await page.click('#pet');
await page.click('#begin');
await page.waitForFunction(() => document.getElementById('call-state')?.textContent === '通话中');
await page.evaluate(async () => {
const { RecentBubble } = await import('/recent-bubble.mjs');
const bubble = new RecentBubble([
document.getElementById('call-bubble-first'), document.getElementById('call-bubble-second'),
]);
bubble.append('user', `${'مرحبا '.repeat(40)}نهاية الجملة`, '语音');
bubble.append('assistant', `${'שלום '.repeat(40)}סוף המשפט`, '宪宪');
});

const arabic = await textRangeVisibility(page, '.call-recent p[data-role="user"]', 'نهاية الجملة');
const hebrew = await textRangeVisibility(page, '.call-recent p[data-role="assistant"]', 'סוף המשפט');
assert.equal(arabic.visible, true, JSON.stringify(arabic));
assert.equal(hebrew.visible, true, JSON.stringify(hebrew));
assert.equal((await recentRowPresentation(page, '.call-recent p[data-role="user"]')).textDirection, 'rtl');
assert.equal((await recentRowPresentation(page, '.call-recent p[data-role="assistant"]')).textDirection, 'rtl');

await page.evaluate(async () => {
const { RecentBubble } = await import('/recent-bubble.mjs');
const bubble = new RecentBubble([
document.getElementById('call-bubble-first'), document.getElementById('call-bubble-second'),
]);
bubble.append('user', `${'ك'.repeat(50)}iPad ${'مرحبا '.repeat(17)}نهاية الجملة`, '语音');
bubble.append('assistant', `${'word '.repeat(60)}مرحبا ${'word '.repeat(20)}final words!`, '宪宪');
});
const mixedRtl = await textRangeVisibility(page, '.call-recent p[data-role="user"]', 'نهاية الجملة');
const mixedLtr = await textRangeVisibility(page, '.call-recent p[data-role="assistant"]', 'final words!');
assert.equal(mixedRtl.visible, true, JSON.stringify(mixedRtl));
assert.equal(mixedLtr.visible, true, JSON.stringify(mixedLtr));
assert.equal((await recentRowPresentation(page, '.call-recent p[data-role="user"]')).textDirection, 'rtl');
assert.equal((await recentRowPresentation(page, '.call-recent p[data-role="assistant"]')).textDirection, 'ltr');

const adlamTail = String.fromCodePoint(0x1e922, 0x1e923, 0x1e924, 0x1e925, 0x1e926);
await page.evaluate(async ({ adlamPrefix, adlamTail }) => {
const { RecentBubble } = await import('/recent-bubble.mjs');
const row = document.getElementById('call-bubble-first');
const bubble = new RecentBubble([row, document.getElementById('call-bubble-second')]);
bubble.append('user', `${`${adlamPrefix} `.repeat(30)}${adlamTail}`, '语音');
}, {
adlamPrefix: String.fromCodePoint(0x1e927, 0x1e928, 0x1e929, 0x1e92a, 0x1e92b),
adlamTail,
});
const adlam = await textRangeVisibility(page, '.call-recent p[data-role="user"]', adlamTail);
assert.equal(adlam.visible, true, JSON.stringify(adlam));
assert.equal((await recentRowPresentation(page, '.call-recent p[data-role="user"]')).textDirection, 'rtl');

const streaming = await page.evaluate(async () => {
const { RecentBubble } = await import('/recent-bubble.mjs');
const row = document.getElementById('call-bubble-first');
const bubble = new RecentBubble([row, document.getElementById('call-bubble-second')]);
bubble.append('assistant', 'hello world ', '宪宪');
const bad = [];
for (let index = 0; index < 60; index += 1) {
const delta = `كلمة${index} `;
bubble.append('assistant', delta, '宪宪');
const message = row.querySelector('.recent-message');
const text = row.querySelector('.recent-message-text').firstChild;
const offset = text.data.lastIndexOf(delta);
const range = document.createRange();
range.setStart(text, offset);
range.setEnd(text, offset + delta.length);
const tail = range.getBoundingClientRect();
const bounds = message.getBoundingClientRect();
const hit = document.elementFromPoint((tail.left + tail.right) / 2, (tail.top + tail.bottom) / 2);
if (tail.left < bounds.left - 0.5 || tail.right > bounds.right + 0.5 || !message.contains(hit)) bad.push(index);
}
return bad;
});
assert.deepEqual(streaming, []);
} finally {
await page.close();
}
});

test('recovering shared subtitles keep every header action inside compact and expanded panels', async () => {
const page = await openPreview({ width: 460, height: 700 });
try {
Expand Down
Loading