Merge pull request 'fix chat bubble and @' (#11) from feat/chat_team_mode into main
Reviewed-on: #11
This commit is contained in:
commit
7f12c5f944
File diff suppressed because one or more lines are too long
|
|
@ -67,3 +67,6 @@ export function Download({className,size=16}:IconProps){
|
|||
export function Upload({className,size=16}:IconProps){
|
||||
return <svg className={["animated-icon",className].filter(Boolean).join(" ")} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="17 8 12 3 7 8"/><line x1="12" y1="3" x2="12" y2="15"/></svg>;
|
||||
}
|
||||
export function Reply({className,size=16}:IconProps){
|
||||
return <svg className={["animated-icon",className].filter(Boolean).join(" ")} width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" aria-hidden="true"><polyline points="9 17 4 12 9 7"/><path d="M20 18v-2a4 4 0 0 0-4-4H4"/></svg>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,8 +26,8 @@
|
|||
.thinking-dots i:nth-child(3){animation-delay:.32s}
|
||||
.composer-dock{position:relative;z-index:6;display:flex;flex-direction:column;align-items:center;gap:20px;padding:28px var(--chat-gutter) 20px;background:var(--main);pointer-events:none;flex:none;width:100%}
|
||||
.composer-dock:before{content:"";position:absolute;left:0;right:0;bottom:100%;height:32px;background:linear-gradient(180deg,transparent,var(--main));pointer-events:none}
|
||||
.composer-dock>*{box-sizing:border-box;pointer-events:auto;width:var(--chat-col);max-width:760px;margin-inline:auto}
|
||||
.composer{position:relative;left:auto;right:auto;bottom:auto;width:var(--chat-col);min-height:62px;align-items:center;padding:9px 10px;transform:none}
|
||||
.composer-dock>*{box-sizing:border-box;pointer-events:auto;width:var(--chat-col);max-width:760px;min-width:0;margin-inline:auto}
|
||||
.composer{position:relative;left:auto;right:auto;bottom:auto;width:var(--chat-col);min-width:0;max-width:100%;min-height:62px;align-items:center;padding:9px 10px;transform:none}
|
||||
.composer.has-files{flex-wrap:wrap;align-items:flex-end;padding-top:10px}
|
||||
.composer textarea{align-self:center;box-sizing:border-box;height:42px;min-height:42px;max-height:126px;padding:11px 4px;line-height:20px}
|
||||
.slash-suggestions{position:absolute;left:58px;right:58px;bottom:calc(100% + 8px);z-index:8;display:grid;gap:2px;padding:6px;border:1px solid var(--border);border-radius:14px;background:var(--menu);box-shadow:0 14px 34px rgba(0,0,0,.35)}
|
||||
|
|
@ -52,8 +52,8 @@
|
|||
.stop-send{background:var(--cream);color:var(--on-send)}
|
||||
.stop-send svg{width:14px;height:14px;fill:currentColor}
|
||||
.composer-plus:disabled{opacity:.35;cursor:not-allowed}
|
||||
.message{position:relative;padding-right:28px}
|
||||
.messages .remember-msg{display:grid}
|
||||
.message{position:relative;padding-right:0;-webkit-touch-callout:none;touch-action:manipulation}
|
||||
.messages .remember-msg{display:none}
|
||||
.working-label{font-size:13px;letter-spacing:.02em;line-height:1.35;white-space:nowrap;background:linear-gradient(90deg,#7a8088 0%,#7a8088 28%,#fff 50%,#7a8088 72%,#7a8088 100%);background-size:220% 100%;-webkit-background-clip:text;background-clip:text;color:transparent;animation:working-shimmer 1.35s linear infinite}
|
||||
.message.assistant.spoken{display:grid;grid-template-columns:28px minmax(0,1fr);column-gap:10px;row-gap:3px;justify-content:start;align-items:end}
|
||||
.message.assistant.spoken .msg-avatar{grid-column:1;grid-row:2;align-self:start;max-width:none;margin:4px 0 0;padding:0;border-radius:0;background:transparent;line-height:normal}
|
||||
|
|
@ -76,7 +76,7 @@
|
|||
|
||||
.message{padding-bottom:21px}
|
||||
.message-time{position:absolute;bottom:0;left:0;font-size:11px;line-height:17px;color:var(--muted);font-variant-numeric:tabular-nums}
|
||||
.message.user .message-time{left:auto;right:28px}
|
||||
.message.user .message-time{left:auto;right:0}
|
||||
|
||||
/* The day is said once, centred above the first message of that day, so each
|
||||
message only needs its clock time. */
|
||||
|
|
@ -86,9 +86,31 @@
|
|||
/* Who the room sent a message to: one thin line in the space under the bubble,
|
||||
opposite the timestamp, so it reads as a note about the message and never as
|
||||
a second message. */
|
||||
.route-note{position:absolute;bottom:0;left:0;max-width:56%;overflow:hidden;color:var(--muted);font-size:11px;line-height:17px;opacity:.78;text-overflow:ellipsis;white-space:nowrap}
|
||||
.message.user .route-note{left:0;right:auto}
|
||||
.message.assistant .route-note{left:auto;right:28px;text-align:right}
|
||||
.route-note{display:none}
|
||||
|
||||
.composer.has-reply{flex-wrap:wrap;align-items:flex-end;padding-top:10px}
|
||||
.composer-reply{display:flex;align-items:center;gap:8px;box-sizing:border-box;flex:1 1 100%;min-width:0;max-width:100%;width:100%;overflow:hidden;order:-2;margin:2px 0 4px;padding:7px 8px 7px 46px;border-left:2px solid var(--accent);border-radius:10px;background:rgba(255,255,255,.04)}
|
||||
.composer-reply-copy{min-width:0;flex:1 1 auto;overflow:hidden}
|
||||
.composer-reply-copy small,.composer-reply-copy span{display:block;overflow:hidden;min-width:0;text-overflow:ellipsis;white-space:nowrap}
|
||||
.composer-reply-copy small{color:var(--accent);font-size:11px;font-weight:650;line-height:1.3}
|
||||
.composer-reply-copy span{color:var(--muted);font-size:12px;line-height:1.35}
|
||||
.composer-reply-close{flex:0 0 28px;width:28px;height:28px;display:grid;place-items:center;border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer}
|
||||
.composer-reply-close:hover{color:var(--ink);background:rgba(255,255,255,.08)}
|
||||
.composer-reply-close svg{width:14px;height:14px}
|
||||
|
||||
.reply-quote{display:block;margin:0 0 8px;padding:6px 10px;border-left:2px solid var(--accent);border-radius:8px;background:rgba(255,255,255,.05);white-space:normal}
|
||||
.reply-quote strong{display:block;color:var(--accent);font-size:11px;font-weight:650;line-height:1.3}
|
||||
.reply-quote span{display:block;overflow:hidden;color:var(--muted);font-size:12px;line-height:1.35;text-overflow:ellipsis;white-space:nowrap}
|
||||
.message.user .reply-quote{background:rgba(0,0,0,.08)}
|
||||
.message.user .reply-quote strong{color:var(--on-cream);opacity:.72}
|
||||
.message.user .reply-quote span{color:var(--on-cream);opacity:.7}
|
||||
.message.replying>.message-body,.message.replying>.message-stack>.message-body{box-shadow:inset 0 0 0 1px var(--accent)}
|
||||
|
||||
.mention-link{display:inline;margin:0;padding:0;border:0;border-radius:0;background:none;color:var(--accent);font:inherit;line-height:inherit;text-decoration:underline;text-decoration-color:color-mix(in srgb,var(--accent) 55%,transparent);text-underline-offset:.16em;cursor:pointer}
|
||||
.mention-link:hover{color:#7ee0c8}
|
||||
.mention-link.is-everyone{cursor:default;font-weight:650}
|
||||
.message.user .mention-link{color:#0f766e;text-decoration-color:color-mix(in srgb,#0f766e 50%,transparent)}
|
||||
.message.user .mention-link:hover{color:#115e59}
|
||||
|
||||
/* The `@` list sits where the slash list sits: only one of them is ever open. */
|
||||
.mention-suggestions button{align-items:center;gap:8px}
|
||||
|
|
|
|||
|
|
@ -58,6 +58,7 @@ export const en: { [K in keyof typeof zhTW]: string } = {
|
|||
startBotWork: "Start working with {name}",
|
||||
roomWillReply: "{names} are here. Whoever is @-mentioned answers; otherwise the host, or the best fit, picks it up.",
|
||||
everyoneMention: "everyone", mentionEveryoneHint: "every member answers", mentionList: "Members to mention",
|
||||
openAgentChat: "Open chat with {name}",
|
||||
routedTo: "{names} will answer this", handedTo: "→ handed to {name}",
|
||||
today: "Today", yesterday: "Yesterday",
|
||||
hostBadge: "Host", hostMenuLabel: "Who hosts", hostHint: "The host answers what nobody is named for. Tap to change it.",
|
||||
|
|
@ -69,6 +70,10 @@ export const en: { [K in keyof typeof zhTW]: string } = {
|
|||
copiedCode: "Copied",
|
||||
copyMessage: "Copy",
|
||||
copiedMessage: "Copied",
|
||||
replyToThis: "Reply to this",
|
||||
replyingTo: "Replying to {name}",
|
||||
cancelReply: "Cancel reply",
|
||||
you: "You",
|
||||
anotherConversationQueued: "Another chat is running, so this one is queued.",
|
||||
workingStep: "{name} is working… {step}",
|
||||
queuedMessages: "{count} more messages queued",
|
||||
|
|
|
|||
|
|
@ -16,11 +16,13 @@ export const zhTW = {
|
|||
openOnPhone: "在手機開啟", settings: "設定", about: "關於", helpCenter: "說明中心", sendFeedback: "傳送意見回饋", logout: "登出", workspaceMenu: "工作區選單",
|
||||
chooseBot: "選擇一個機器人", startRoomDiscussion: "和 {name} 開始討論", startBotWork: "和 {name} 開始工作", roomWillReply: "{names} 都在這裡。@誰就由誰回覆,沒點名時由主持人或最合適的 Agent 接手。",
|
||||
everyoneMention: "所有人", mentionEveryoneHint: "每個人都回覆", mentionList: "可點名的成員",
|
||||
openAgentChat: "開啟與 {name} 的對話",
|
||||
routedTo: "本輪由 {names} 回覆", handedTo: "→ 交給 {name}",
|
||||
today: "今天", yesterday: "昨天",
|
||||
hostBadge: "主持人", hostMenuLabel: "由誰主持", hostHint: "沒被點名的時候由主持人回答。點一下換人。",
|
||||
botWelcome: "傳送訊息,讓它在自己的電腦上完成任務。", remembered: "已記住", remember: "記住", working: "{name} 正在工作…",
|
||||
copyCode: "複製程式碼", copiedCode: "已複製", copyMessage: "複製", copiedMessage: "已複製",
|
||||
replyToThis: "回覆這個", replyingTo: "回覆 {name}", cancelReply: "取消回覆", you: "你",
|
||||
anotherConversationQueued: "另一則對話正在執行,這則會排隊。",
|
||||
workingStep: "{name} 正在工作… {step}", queuedMessages: "還有 {count} 則訊息排隊中",
|
||||
pausedUserControl: "你正在操控畫面,{name} 已暫停。完成後按「釋放控制」,它會從目前畫面接著做(排隊中的訊息也會一起處理)。",
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
import { memo, useCallback, useRef, useState, type ComponentPropsWithoutRef } from "react";
|
||||
import { Children, cloneElement, createElement, isValidElement, memo, useCallback, useMemo, useRef, useState, type ComponentPropsWithoutRef, type ReactNode } from "react";
|
||||
import ReactMarkdown, { type Components } from "react-markdown";
|
||||
import remarkBreaks from "remark-breaks";
|
||||
import remarkGfm from "remark-gfm";
|
||||
import { t } from "./i18n";
|
||||
import { mentionSpans, type MentionAgent } from "./mentions";
|
||||
import "./markdown.css";
|
||||
|
||||
const protocolPattern = /^([a-z][a-z\d+.-]*):/i;
|
||||
|
|
@ -44,7 +45,7 @@ function useCopiedFlag() {
|
|||
return {copied, markCopied};
|
||||
}
|
||||
|
||||
function copyText(text: string): Promise<boolean> {
|
||||
export function copyText(text: string): Promise<boolean> {
|
||||
if (!navigator.clipboard) return Promise.resolve(false);
|
||||
return navigator.clipboard.writeText(text).then(() => true).catch(() => false);
|
||||
}
|
||||
|
|
@ -94,11 +95,88 @@ export function CopyMessageButton({text}:{text:string}) {
|
|||
);
|
||||
}
|
||||
|
||||
export const ChatMarkdown = memo(function ChatMarkdown({children}:{children:string}) {
|
||||
export function MentionText({
|
||||
text,
|
||||
agents,
|
||||
onMention,
|
||||
}:{
|
||||
text:string;
|
||||
agents:MentionAgent[];
|
||||
onMention?:(id:string)=>void;
|
||||
}) {
|
||||
const spans=mentionSpans(text,agents);
|
||||
if(!spans.length)return null;
|
||||
if(spans.length===1&&spans[0].kind==="text")return text;
|
||||
return <>{spans.map((span,index)=>{
|
||||
if(span.kind==="text")return <span key={index}>{span.text}</span>;
|
||||
if(span.kind==="everyone")return <span key={index} className="mention-link is-everyone">{span.text}</span>;
|
||||
const name=span.text.slice(1);
|
||||
return <button
|
||||
key={index}
|
||||
type="button"
|
||||
className="mention-link"
|
||||
title={t("openAgentChat",{name})}
|
||||
aria-label={t("openAgentChat",{name})}
|
||||
onClick={event=>{event.preventDefault();event.stopPropagation();onMention?.(span.id);}}
|
||||
>{span.text}</button>;
|
||||
})}</>;
|
||||
}
|
||||
|
||||
const SKIP_TAGS=new Set(["code","pre","a"]);
|
||||
|
||||
function linkifyMentions(node:ReactNode,agents:MentionAgent[],onMention?:(id:string)=>void):ReactNode {
|
||||
if(node==null||typeof node==="boolean")return node;
|
||||
if(typeof node==="number")return node;
|
||||
if(typeof node==="string")return <MentionText text={node} agents={agents} onMention={onMention}/>;
|
||||
if(Array.isArray(node))return Children.map(node,child=>linkifyMentions(child,agents,onMention));
|
||||
if(isValidElement(node)){
|
||||
if(typeof node.type!=="string")return node;
|
||||
if(SKIP_TAGS.has(node.type))return node;
|
||||
const kids=(node.props as {children?:ReactNode}).children;
|
||||
if(kids==null)return node;
|
||||
return cloneElement(node,undefined,linkifyMentions(kids,agents,onMention));
|
||||
}
|
||||
return node;
|
||||
}
|
||||
|
||||
function mentionComponents(agents:MentionAgent[],onMention?:(id:string)=>void):Components {
|
||||
const wrap=(tag:"p"|"li"|"td"|"th"|"h1"|"h2"|"h3"|"h4"|"h5"|"h6"|"blockquote")=>
|
||||
function MentionTag({node:_node,children,...props}:{node?:unknown;children?:ReactNode}){
|
||||
return createElement(tag,props,linkifyMentions(children,agents,onMention));
|
||||
};
|
||||
return {
|
||||
...components,
|
||||
p:wrap("p"),
|
||||
li:wrap("li"),
|
||||
td:wrap("td"),
|
||||
th:wrap("th"),
|
||||
h1:wrap("h1"),
|
||||
h2:wrap("h2"),
|
||||
h3:wrap("h3"),
|
||||
h4:wrap("h4"),
|
||||
h5:wrap("h5"),
|
||||
h6:wrap("h6"),
|
||||
blockquote:wrap("blockquote"),
|
||||
};
|
||||
}
|
||||
|
||||
export const ChatMarkdown = memo(function ChatMarkdown({
|
||||
children,
|
||||
agents=[],
|
||||
onMention,
|
||||
}:{
|
||||
children:string;
|
||||
agents?:MentionAgent[];
|
||||
onMention?:(id:string)=>void;
|
||||
}) {
|
||||
const mdComponents=useMemo(
|
||||
()=>agents.length?mentionComponents(agents,onMention):components,
|
||||
[agents,onMention],
|
||||
);
|
||||
return (
|
||||
<div className="md-body">
|
||||
<ReactMarkdown
|
||||
components={components}
|
||||
components={mdComponents}
|
||||
remarkPlugins={[remarkGfm, remarkBreaks]}
|
||||
skipHtml
|
||||
urlTransform={(url) => sanitizeMarkdownUrl(url) ?? ""}
|
||||
|
|
|
|||
|
|
@ -54,3 +54,107 @@ export function acceptMention(
|
|||
const inserted = `${head}@${name} `;
|
||||
return { text: inserted + text.slice(end), caret: inserted.length };
|
||||
}
|
||||
|
||||
function escapeRegExp(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
||||
}
|
||||
|
||||
/**
|
||||
* Put `@name` at the front of a reply so the room routes it, without doubling
|
||||
* a mention that is already in the draft.
|
||||
*/
|
||||
export function ensureMention(text: string, name: string): string {
|
||||
const token = `@${name.trim()}`;
|
||||
if (token === "@") return text;
|
||||
if (new RegExp(`(?:^|\\s)${escapeRegExp(token)}(?=\\s|$)`).test(text)) return text;
|
||||
const trimmed = text.trimStart();
|
||||
return trimmed ? `${token} ${trimmed}` : `${token} `;
|
||||
}
|
||||
|
||||
/** One line for a reply preview or quote chip. */
|
||||
export function replySnippet(body: string, max = 80): string {
|
||||
const text = body.trim().replace(/\s+/g, " ");
|
||||
if (!text) return "";
|
||||
return text.length <= max ? text : `${text.slice(0, Math.max(1, max - 1))}…`;
|
||||
}
|
||||
|
||||
/** Same spellings the server treats as “the whole room”. */
|
||||
export const EVERYONE_KEYWORDS = ["所有人", "全部", "all", "everyone", "everybody"] as const;
|
||||
|
||||
export type MentionAgent = { id: string; name: string };
|
||||
export type MentionSpan =
|
||||
| { kind: "text"; text: string }
|
||||
| { kind: "agent"; text: string; id: string }
|
||||
| { kind: "everyone"; text: string };
|
||||
|
||||
/**
|
||||
* Does `tail` open with `name`? ASCII names need a word boundary after them so
|
||||
* `@Ali` cannot pick "Alice"; CJK names run together, so the longest match is
|
||||
* already the boundary.
|
||||
*/
|
||||
export function nameHit(tail: string, name: string): boolean {
|
||||
const lowered = tail.toLowerCase();
|
||||
const needle = name.trim().toLowerCase();
|
||||
if (!needle || !lowered.startsWith(needle)) return false;
|
||||
const needleChars = Array.from(needle);
|
||||
const last = needleChars[needleChars.length - 1];
|
||||
if (!/^[0-9a-z]$/i.test(last)) return true;
|
||||
const next = Array.from(lowered)[needleChars.length];
|
||||
if (next === undefined) return true;
|
||||
return !/^[0-9a-z_-]$/i.test(next);
|
||||
}
|
||||
|
||||
function isWordChar(ch: string): boolean {
|
||||
return /\p{L}|\p{N}/u.test(ch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Split a message into plain text and `@name` runs so the chat can link a
|
||||
* mention to that agent’s own conversation. Longest name wins; `mail@x.com`
|
||||
* is not a mention.
|
||||
*/
|
||||
function charsOf(value: string): string[] {
|
||||
return Array.from(value);
|
||||
}
|
||||
|
||||
export function mentionSpans(text: string, agents: MentionAgent[]): MentionSpan[] {
|
||||
if (!text) return [];
|
||||
if (!agents.length) return [{ kind: "text", text }];
|
||||
const ranked = agents
|
||||
.filter((agent) => agent.name.trim())
|
||||
.sort((a, b) => charsOf(b.name.trim()).length - charsOf(a.name.trim()).length);
|
||||
const chars = charsOf(text);
|
||||
const spans: MentionSpan[] = [];
|
||||
let buffer = "";
|
||||
const flush = () => {
|
||||
if (!buffer) return;
|
||||
spans.push({ kind: "text", text: buffer });
|
||||
buffer = "";
|
||||
};
|
||||
for (let index = 0; index < chars.length; ) {
|
||||
const ch = chars[index];
|
||||
if (ch === "@" && (index === 0 || !isWordChar(chars[index - 1]))) {
|
||||
const tail = chars.slice(index + 1).join("");
|
||||
const everyone = EVERYONE_KEYWORDS.find((word) => nameHit(tail, word));
|
||||
if (everyone) {
|
||||
const length = charsOf(everyone).length;
|
||||
flush();
|
||||
spans.push({ kind: "everyone", text: `@${chars.slice(index + 1, index + 1 + length).join("")}` });
|
||||
index += 1 + length;
|
||||
continue;
|
||||
}
|
||||
const agent = ranked.find((item) => nameHit(tail, item.name));
|
||||
if (agent) {
|
||||
const length = charsOf(agent.name.trim()).length;
|
||||
flush();
|
||||
spans.push({ kind: "agent", text: `@${chars.slice(index + 1, index + 1 + length).join("")}`, id: agent.id });
|
||||
index += 1 + length;
|
||||
continue;
|
||||
}
|
||||
}
|
||||
buffer += ch;
|
||||
index += 1;
|
||||
}
|
||||
flush();
|
||||
return spans;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -471,16 +471,7 @@
|
|||
.pause-banner .primary{flex:0 0 auto;white-space:nowrap}
|
||||
|
||||
|
||||
.remember-msg{position:absolute;top:8px;right:4px;width:26px;height:26px;display:grid;place-items:center;border:0;border-radius:8px;background:transparent;color:var(--muted);cursor:pointer}
|
||||
|
||||
|
||||
.remember-msg svg{width:14px;height:14px}
|
||||
|
||||
|
||||
.remember-msg:hover:not(:disabled){background:rgba(255,255,255,.07);color:var(--ink)}
|
||||
|
||||
|
||||
.remember-msg.saved,.remember-msg:disabled{color:var(--accent);opacity:.9;cursor:default}
|
||||
.remember-msg{display:none}
|
||||
|
||||
|
||||
.panel-open-btn{display:none}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
@media(max-width:700px){.app-shell{display:block}.sidebar{position:fixed;z-index:40;inset:0 auto 0 0;width:min(300px,88vw);transform:translateX(-105%);transition:.2s;box-shadow:20px 0 60px #000}.sidebar.open{transform:none}.chat-panel{height:100%}.mobile-menu{display:inline-flex}.topbar{padding:0 12px}.messages{padding:24px 18px 16px}.composer{left:12px;right:12px}.computer-overlay>header{height:auto;min-height:64px;flex-wrap:wrap;padding:10px}.computer-overlay>header>div:last-child{flex-wrap:wrap;justify-content:flex-end}.overlay-screen{padding:8px}.mode-grid{grid-template-columns:1fr}}
|
||||
@media(max-width:1200px){.app-shell{grid-template-columns:230px minmax(0,1fr) 330px}}
|
||||
@media(max-width:1050px){.app-shell{grid-template-columns:250px minmax(0,1fr)}}
|
||||
@media(max-width:700px){.chat-panel{--chat-gutter:18px}.app-shell{display:block}.message{width:100%}.day-divider{width:100%}.message>span,.message>.message-body,.message-stack{max-width:90%}.composer-dock{padding:20px var(--chat-gutter) 16px}.composer{width:var(--chat-col);min-height:60px}.composer-files{padding:2px 4px 8px 8px}.file-card{max-width:100%;height:52px}.file-card-remove{flex-basis:32px;width:32px;height:32px}.message.with-files .msg-attachments{max-width:100%}.messages{padding-bottom:16px}}
|
||||
@media(max-width:700px){.chat-panel{--chat-gutter:18px}.app-shell{display:block}.message{width:100%}.day-divider{width:100%}.message>span,.message>.message-body,.message-stack{max-width:90%}.composer-dock{padding:20px var(--chat-gutter) 16px}.composer{width:var(--chat-col);min-height:60px}.composer-files{padding:2px 4px 8px 8px}.composer-reply{margin:2px 0 4px;padding-left:12px}.file-card{max-width:100%;height:52px}.file-card-remove{flex-basis:32px;width:32px;height:32px}.message.with-files .msg-attachments{max-width:100%}.messages{padding-bottom:16px}.context-menu{width:min(220px,calc(100vw - 24px))}.context-menu button{height:44px}}
|
||||
@media(prefers-reduced-motion:reduce){.avatar.blobatar.thinking:before,.avatar.blobatar.thinking:after,.thinking-dots i{animation:none!important}}
|
||||
@media(max-width:1200px){.app-shell.right-open{grid-template-columns:230px minmax(0,1fr) 340px}.app-shell.right-collapsed{grid-template-columns:230px minmax(0,1fr) 52px}}
|
||||
@media(max-width:1050px){
|
||||
|
|
|
|||
|
|
@ -148,6 +148,31 @@ pub async fn stage_for_bots(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn extra_prompt_notes(blocks: &[Value], file_count: usize) -> Vec<String> {
|
||||
let mut notes = Vec::new();
|
||||
for block in blocks {
|
||||
if block.get("kind").and_then(Value::as_str) != Some("reply") {
|
||||
continue;
|
||||
}
|
||||
let name = block.get("name").and_then(Value::as_str).unwrap_or("");
|
||||
let body = block.get("body").and_then(Value::as_str).unwrap_or("");
|
||||
if name.is_empty() && body.is_empty() {
|
||||
continue;
|
||||
}
|
||||
notes.push(if name.is_empty() {
|
||||
format!("The user is replying to: {body}")
|
||||
} else {
|
||||
format!("The user is replying to {name}: {body}")
|
||||
});
|
||||
}
|
||||
if file_count > 0 {
|
||||
notes.push(format!(
|
||||
"User attached {file_count} file(s). Chat history does not keep the bytes. Copies live in inbox/ on this computer and expire after 2 hours."
|
||||
));
|
||||
}
|
||||
notes
|
||||
}
|
||||
|
||||
pub async fn llm_parts(
|
||||
state: &AppState,
|
||||
actor: &Actor,
|
||||
|
|
@ -156,17 +181,11 @@ pub async fn llm_parts(
|
|||
vision: bool,
|
||||
) -> Vec<UserContent> {
|
||||
let files = load_from_blocks(state, actor, bot_id, blocks).await;
|
||||
if files.is_empty() && blocks.is_empty() {
|
||||
let mut notes = extra_prompt_notes(blocks, files.len());
|
||||
if files.is_empty() && notes.is_empty() {
|
||||
return Vec::new();
|
||||
}
|
||||
let mut parts = Vec::new();
|
||||
let mut notes = Vec::new();
|
||||
if !blocks.is_empty() {
|
||||
notes.push(format!(
|
||||
"User attached {} file(s). Chat history does not keep the bytes. Copies live in inbox/ on this computer and expire after 2 hours.",
|
||||
blocks.len()
|
||||
));
|
||||
}
|
||||
for (stored, bytes) in files {
|
||||
notes.push(format!(
|
||||
"- {} ({}, {} bytes) at {}",
|
||||
|
|
@ -507,6 +526,17 @@ mod tests {
|
|||
assert!(!allowed_mime("application/x-msdownload"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reply_blocks_are_not_counted_as_files() {
|
||||
let reply = json!({"kind":"reply","name":"阿狗","body":"在這等你"});
|
||||
let notes = extra_prompt_notes(&[reply], 0);
|
||||
assert_eq!(notes, vec!["The user is replying to 阿狗: 在這等你".to_string()]);
|
||||
assert!(extra_prompt_notes(&[json!({"kind":"login","site":"x"})], 0).is_empty());
|
||||
let mixed = extra_prompt_notes(&[json!({"kind":"reply","name":"小美","body":"hi"})], 2);
|
||||
assert!(mixed[0].contains("小美"));
|
||||
assert!(mixed[1].contains("2 file(s)"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_blocks_drop_bytes() {
|
||||
let files = [DecodedAttachment {
|
||||
|
|
|
|||
|
|
@ -44,10 +44,11 @@ test('VNC paste delegates once to confirmed backend and rejects another source',
|
|||
|
||||
const mdJs=ts.transpileModule(fs.readFileSync('apps/web/src/markdown.tsx','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS,jsx:ts.JsxEmit.ReactJSX}}).outputText;
|
||||
const mdBox={exports:{},require:(name)=>{
|
||||
if(name==='react')return{useCallback:fn=>fn,useRef:()=>({current:null}),useState:()=>[false,()=>{}],memo:fn=>fn};
|
||||
if(name==='react')return{useCallback:fn=>fn,useRef:()=>({current:null}),useState:()=>[false,()=>{}],useMemo:fn=>fn(),memo:fn=>fn,Children:{map:(n,fn)=>n},cloneElement:el=>el,isValidElement:()=>false};
|
||||
if(name==='react/jsx-runtime')return{jsx:()=>null,jsxs:()=>null,Fragment:'Fragment'};
|
||||
if(name==='react-markdown'||name==='remark-gfm'||name==='remark-breaks')return{default:()=>null};
|
||||
if(name==='./i18n')return{t:key=>key};
|
||||
if(name==='./mentions')return{mentionSpans:()=>[]};
|
||||
if(name.endsWith('.css'))return{};
|
||||
throw new Error('unexpected import '+name);
|
||||
}};
|
||||
|
|
@ -367,6 +368,20 @@ test('trail lines read as sentences with the detail a stuck run needs',()=>{
|
|||
assert.match(trailText({id:9,kind:'notice',createdAt:'',text:'這輪不需要電腦'}),/這輪不需要電腦/);
|
||||
});
|
||||
|
||||
test('chat messages use a copy-or-reply menu instead of inline run details',()=>{
|
||||
const app=fs.readFileSync('apps/web/src/App.tsx','utf8');
|
||||
assert.match(app,/function MessageContextMenu/);
|
||||
assert.match(app,/t\("replyToThis"\)/);
|
||||
assert.match(app,/t\("copyMessage"\)/);
|
||||
assert.match(app,/\{\.\.\.messagePress\(message\)\}/);
|
||||
assert.match(app,/if\(message\.role==="user"\)return/);
|
||||
assert.match(app,/canReply=context\.message\.role!=="user"/);
|
||||
assert.match(app,/kind:"reply"/);
|
||||
assert.doesNotMatch(app,/CopyMessageButton/);
|
||||
assert.doesNotMatch(app,/remember-msg/);
|
||||
assert.doesNotMatch(app,/className="route-note"/);
|
||||
});
|
||||
|
||||
test('the bubble reads the run activity endpoint the API actually mounts',()=>{
|
||||
const monitor=fs.readFileSync('crates/api/src/monitor.rs','utf8');
|
||||
assert.match(monitor,/\.route\("\/api\/runs\/\{id\}\/activity", get\(activity\)\)/);
|
||||
|
|
@ -376,7 +391,9 @@ test('the bubble reads the run activity endpoint the API actually mounts',()=>{
|
|||
assert.match(app,/errorActions\(chip\.code\)/);
|
||||
assert.match(app,/`\/api\/runs\/\$\{runId\}\/retry`,\{method:"POST",body:"\{\}"\}/);
|
||||
assert.match(app,/<RunProbe runId=\{chip\.runId\|\|null\} align="end" label=\{t\("errorDetails"\)\}/);
|
||||
assert.match(app,/<RunProbe runId=\{member\.id===computer\.botId\?computer\.busyRunId:null\}><Avatar/);
|
||||
assert.match(app,/<RunProbe runId=\{computer\.busyRunId\|\|computer\.waitingRunId\}>/);
|
||||
assert.doesNotMatch(app,/message-run-details/);
|
||||
assert.match(app,/className="thinking-row"/);
|
||||
const probe=fs.readFileSync('apps/web/src/run-monitor.tsx','utf8');
|
||||
assert.match(probe,/`\/api\/runs\/\$\{runId\}\/activity\$\{after\}`/);
|
||||
assert.doesNotMatch(probe,/export function RunStatus/);
|
||||
|
|
@ -629,7 +646,7 @@ for(const hostUpdatesDuringReconnect of [false,true])test(`reconnect preserves s
|
|||
const mentionsJs=ts.transpileModule(fs.readFileSync('apps/web/src/mentions.ts','utf8'),{compilerOptions:{module:ts.ModuleKind.CommonJS}}).outputText;
|
||||
const mentionsBox={exports:{},require:()=>{throw new Error('unexpected import')}};
|
||||
vm.runInNewContext(mentionsJs,mentionsBox);
|
||||
const {mentionToken,mentionChoices,acceptMention}=mentionsBox.exports;
|
||||
const {mentionToken,mentionChoices,acceptMention,ensureMention,replySnippet,mentionSpans,nameHit}=mentionsBox.exports;
|
||||
const EVERYONE='所有人';
|
||||
const roster=[{id:'b1',name:'小美'},{id:'b2',name:'Alex'},{id:'b3',name:'Alexa'},{id:'b4',name:'工程小助手'}];
|
||||
// The list is built inside a vm, so its arrays are not this realm's arrays:
|
||||
|
|
@ -676,6 +693,40 @@ test('accepting a mention leaves the sentence readable and the list closed',()=>
|
|||
assert.equal(acceptMention('@小',2,'小美').text,'@小美 ','a half-typed name is replaced whole');
|
||||
});
|
||||
|
||||
test('replying to a room message names the speaker once',()=>{
|
||||
assert.equal(ensureMention('','小美'),'@小美 ');
|
||||
assert.equal(ensureMention('看一下這個','小美'),'@小美 看一下這個');
|
||||
assert.equal(ensureMention('@小美 已經點名','小美'),'@小美 已經點名','an existing mention is left alone');
|
||||
assert.equal(ensureMention('請 @小美 幫忙','小美'),'請 @小美 幫忙','a mention later in the sentence also counts');
|
||||
assert.equal(ensureMention(' 前面有空白','Alex'),'@Alex 前面有空白');
|
||||
assert.equal(ensureMention('',''),'');
|
||||
assert.equal(replySnippet('沒拚嘛,在這等你聊天~有事直接說就好。'),'沒拚嘛,在這等你聊天~有事直接說就好。');
|
||||
assert.equal(replySnippet('one\n\ntwo three'),'one two three');
|
||||
const long='字'.repeat(90);
|
||||
assert.equal(replySnippet(long).length,80);
|
||||
assert.ok(replySnippet(long).endsWith('…'));
|
||||
});
|
||||
|
||||
test('group @mentions become agent spans and ignore emails',()=>{
|
||||
const room=[{id:'b',name:'小美'},{id:'a',name:'阿明'},{id:'c',name:'Dev'}];
|
||||
const kinds=(text)=>mentionSpans(text,room).map(span=>span.kind==='agent'?`${span.kind}:${span.id}`:`${span.kind}:${span.text}`).join('|');
|
||||
assert.equal(kinds('@小美 幫我看這份報表'),'agent:b|text: 幫我看這份報表');
|
||||
assert.equal(kinds('@阿明哥 來一下'),'agent:a|text:哥 來一下','a CJK name has no word boundary, so 阿明 matches inside 阿明哥 when that longer member is absent');
|
||||
const overlap=[{id:'a',name:'阿明'},{id:'b',name:'阿明哥'}];
|
||||
assert.equal(mentionSpans('@阿明哥 來一下',overlap)[0].id,'b');
|
||||
assert.equal(mentionSpans('@阿明 來一下',overlap)[0].id,'a');
|
||||
assert.equal(kinds('@dev 幫我看'),'agent:c|text: 幫我看');
|
||||
assert.equal(kinds('@Dev, help'),'agent:c|text:, help');
|
||||
assert.equal(kinds('@Developer 幫我看'),'text:@Developer 幫我看');
|
||||
assert.equal(kinds('寄到 mail@小美 或 report@dev.example 就好'),'text:寄到 mail@小美 或 report@dev.example 就好');
|
||||
assert.equal(kinds('(@小美)'),'text:(|agent:b|text:)');
|
||||
assert.equal(mentionSpans('@所有人 結論定了',room)[0].kind,'everyone');
|
||||
assert.equal(mentionSpans('@everyone look',room)[0].kind,'everyone');
|
||||
assert.ok(nameHit('小美 幫我','小美'));
|
||||
assert.equal(mentionSpans('@allison 來了',room)[0].kind,'text');
|
||||
assert.equal(mentionSpans('@小美 幫我看',[])[0].kind,'text','one-to-one chats do not link mentions');
|
||||
});
|
||||
|
||||
// When a message was said is split the way chat apps split it: the day once,
|
||||
// as a divider, and the clock time on each message. The words for today and
|
||||
// yesterday come from the catalog; everything else is the locale's own spelling.
|
||||
|
|
|
|||
Loading…
Reference in New Issue