File size: 4,989 Bytes
9b9eafc | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 | import { useEffect, useRef, type KeyboardEvent } from 'react';
import type { AgentMessage, AssistantMessage, ToolCall, ToolResultMessage } from '../shared/types';
import { PiLogo } from './TopBar';
function ToolCallView({ call }: { call: ToolCall }) {
const entries = Object.entries(call.arguments);
return (
<div className="tool">
<div className="tool-head">{call.name}</div>
{call.name === 'bash' && typeof call.arguments.command === 'string' ? (
<pre>$ {call.arguments.command}</pre>
) : (
entries.map(([k, v]) => (
<div className="kv" key={k}>
<b>{k}</b>
<pre>{String(v)}</pre>
</div>
))
)}
</div>
);
}
function ResultView({ m }: { m: ToolResultMessage }) {
const text = m.content.map((c) => c.text).join('\n');
return (
<details className={`result${m.isError ? ' bad' : ''}`} open={text.length < 400}>
<summary>
{m.toolName} result{m.details.exitCode !== undefined ? ` · exit ${m.details.exitCode}` : ''}
{m.isError ? ' · error' : ''}
</summary>
<pre>{text}</pre>
</details>
);
}
function AssistantView({ m, live }: { m: AssistantMessage; live?: boolean }) {
return (
<div className="msg assistant">
{m.content.map((c, i) =>
c.type === 'thinking' ? (
<details key={i} className="thinking" open={live}>
<summary>Thinking</summary>
<pre>{c.thinking}</pre>
</details>
) : c.type === 'text' ? (
<p key={i} className="text">
{c.text}
</p>
) : (
<ToolCallView key={i} call={c} />
),
)}
{live && m.content.length === 0 && <span className="typing" aria-label="Working"><i /><i /><i /></span>}
{m.stopReason === 'error' && <p className="err">{m.errorMessage}</p>}
{m.stopReason === 'aborted' && <p className="muted">Stopped.</p>}
{m.stopReason === 'length' && <p className="muted">Stopped at the output limit.</p>}
</div>
);
}
interface Props {
messages: AgentMessage[];
streaming: AssistantMessage | null;
busy: string | null;
draft: string;
contextTokens: number;
onDraft: (v: string) => void;
onSend: () => void;
onStop: () => void;
onNewChat: () => void;
}
export function ChatPanel({ messages, streaming, busy, draft, contextTokens, onDraft, onSend, onStop, onNewChat }: Props) {
const logRef = useRef<HTMLDivElement>(null);
const working = busy === 'prompt';
const last = messages[messages.length - 1];
useEffect(() => {
const el = logRef.current;
if (el) el.scrollTop = el.scrollHeight;
}, [messages, streaming]);
const onKey = (e: KeyboardEvent<HTMLTextAreaElement>) => {
// Không gửi khi đang gõ dấu bằng bộ gõ (tiếng Việt, tiếng Trung…).
if (e.key === 'Enter' && !e.shiftKey && !e.nativeEvent.isComposing) {
e.preventDefault();
onSend();
}
};
return (
<section className="chat" aria-label="Agent conversation">
<div className="log" ref={logRef} role="log" aria-live="polite">
{messages.length === 0 && !working && (
<div className="empty">
<PiLogo />
<h2>Ask Pi to work in your workspace</h2>
<p>Pi can read, write and edit files and run shell commands. Everything stays in this browser.</p>
<button onClick={() => onDraft("What's our total sales revenue?")}>What's our total sales revenue?</button>
</div>
)}
{messages.map((m, i) =>
m.role === 'user' ? (
<div key={i} className="msg user">{m.content}</div>
) : m.role === 'assistant' ? (
<AssistantView key={i} m={m} />
) : (
<ResultView key={i} m={m} />
),
)}
{working && streaming && <AssistantView m={streaming} live />}
{working && !streaming && last && last.role !== 'assistant' && (
<div className="msg assistant"><span className="typing" aria-label="Working"><i /><i /><i /></span></div>
)}
</div>
<div className="composer">
<label className="sr" htmlFor="prompt">Message Pi</label>
<textarea
id="prompt"
value={draft}
onChange={(e) => onDraft(e.target.value)}
onKeyDown={onKey}
placeholder="Message Pi… (Enter to send, Shift+Enter for a new line)"
rows={2}
/>
<div className="controls">
<button onClick={onNewChat} disabled={busy !== null || messages.length === 0}>New chat</button>
<span className="grow">{contextTokens > 0 ? `${contextTokens.toLocaleString()} / 8,192 tokens` : 'Model and tools run in your browser'}</span>
{busy !== null && <button onClick={onStop}>Stop</button>}
<button className="send" onClick={onSend} disabled={busy !== null || !draft.trim()}>Send ↑</button>
</div>
</div>
</section>
);
}
|