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&apos;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>
  );
}