Skip to content

Commit 434e2bc

Browse files
committed
working on AI widget context. also working on Tsunami context (AppShortDesc). simplify how to set titles/shortdesc for AI
1 parent 96f869c commit 434e2bc

25 files changed

Lines changed: 271 additions & 99 deletions

File tree

cmd/wsh/cmd/wshcmd-ssh.go

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,7 +80,9 @@ func sshRun(cmd *cobra.Command, args []string) (rtnErr error) {
8080
data := wshrpc.CommandSetMetaData{
8181
ORef: waveobj.MakeORef(waveobj.OType_Block, blockId),
8282
Meta: map[string]any{
83-
waveobj.MetaKey_Connection: sshArg,
83+
waveobj.MetaKey_Connection: sshArg,
84+
waveobj.MetaKey_CmdCwd: nil,
85+
waveobj.MetaKey_CmdHasCurCwd: nil,
8486
},
8587
}
8688
err := wshclient.SetMetaCommand(RpcClient, data, nil)

frontend/app/aipanel/aimessage.tsx

Lines changed: 19 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -34,10 +34,18 @@ const UserMessageFiles = memo(({ fileParts }: UserMessageFilesProps) => {
3434
<div key={index} className="relative bg-gray-700 rounded-lg p-2 min-w-20 flex-shrink-0">
3535
<div className="flex flex-col items-center text-center">
3636
<div className="w-12 h-12 mb-1 flex items-center justify-center bg-gray-600 rounded">
37-
<i className={cn("fa text-lg text-gray-300", getFileIcon(file.data?.filename || '', file.data?.mimetype || ''))}></i>
37+
<i
38+
className={cn(
39+
"fa text-lg text-gray-300",
40+
getFileIcon(file.data?.filename || "", file.data?.mimetype || "")
41+
)}
42+
></i>
3843
</div>
39-
<div className="text-[10px] text-gray-200 truncate w-full max-w-16" title={file.data?.filename || 'File'}>
40-
{file.data?.filename || 'File'}
44+
<div
45+
className="text-[10px] text-gray-200 truncate w-full max-w-16"
46+
title={file.data?.filename || "File"}
47+
>
48+
{file.data?.filename || "File"}
4149
</div>
4250
</div>
4351
</div>
@@ -76,8 +84,6 @@ const AIMessagePart = memo(({ part, role, isStreaming }: AIMessagePartProps) =>
7684
theme: "dark",
7785
darkMode: true,
7886
}}
79-
allowedLinkPrefixes={["https://", "http://", "#"]}
80-
allowedImagePrefixes={["https://", "http://", "data:"]}
8187
defaultOrigin="http://localhost"
8288
>
8389
{content}
@@ -88,11 +94,7 @@ const AIMessagePart = memo(({ part, role, isStreaming }: AIMessagePartProps) =>
8894

8995
if (part.type.startsWith("tool-") && "state" in part && part.state === "input-available") {
9096
const toolName = part.type.substring(5); // Remove "tool-" prefix
91-
return (
92-
<div className="text-gray-400 italic">
93-
Calling tool {toolName}
94-
</div>
95-
);
97+
return <div className="text-gray-400 italic">Calling tool {toolName}</div>;
9698
}
9799

98100
return null;
@@ -106,13 +108,17 @@ interface AIMessageProps {
106108
}
107109

108110
const isDisplayPart = (part: WaveUIMessagePart): boolean => {
109-
return part.type === "text" || (part.type.startsWith("tool-") && "state" in part && part.state === "input-available");
111+
return (
112+
part.type === "text" || (part.type.startsWith("tool-") && "state" in part && part.state === "input-available")
113+
);
110114
};
111115

112116
export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => {
113117
const parts = message.parts || [];
114118
const displayParts = parts.filter(isDisplayPart);
115-
const fileParts = parts.filter((part): part is WaveUIMessagePart & { type: "data-userfile" } => part.type === "data-userfile");
119+
const fileParts = parts.filter(
120+
(part): part is WaveUIMessagePart & { type: "data-userfile" } => part.type === "data-userfile"
121+
);
116122
const hasTextContent = displayParts.length > 0 && displayParts.some((part) => part.type === "text" && part.text);
117123

118124
const showThinking = !hasTextContent && isStreaming && message.role === "assistant";
@@ -138,7 +144,7 @@ export const AIMessage = memo(({ message, isStreaming }: AIMessageProps) => {
138144
</div>
139145
))
140146
)}
141-
147+
142148
{message.role === "user" && <UserMessageFiles fileParts={fileParts} />}
143149
</div>
144150
</div>

frontend/app/aipanel/aipanel.tsx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,12 +25,13 @@ interface AIPanelProps {
2525
const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
2626
const [input, setInput] = useState("");
2727
const [isDragOver, setIsDragOver] = useState(false);
28+
const [errorMessage, setErrorMessage] = useState<string>("");
2829
const modelRef = useRef(new WaveAIModel());
2930
const model = modelRef.current;
3031
const realMessageRef = useRef<AIMessage>(null);
3132
const inputRef = useRef<AIPanelInputRef>(null);
3233

33-
const { messages, sendMessage, status, setMessages } = useChat({
34+
const { messages, sendMessage, status, setMessages, error } = useChat({
3435
transport: new DefaultChatTransport({
3536
api: `${getWebServerEndpoint()}/api/post-chat-message`,
3637
prepareSendMessagesRequest: (opts) => {
@@ -48,6 +49,14 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
4849
}),
4950
onError: (error) => {
5051
console.error("AI Chat error:", error);
52+
setErrorMessage(error.message || "An error occurred");
53+
// Remove the last user message that failed to send
54+
setMessages((prevMessages) => {
55+
if (prevMessages.length > 0 && prevMessages[prevMessages.length - 1].role === "user") {
56+
return prevMessages.slice(0, -1);
57+
}
58+
return prevMessages;
59+
});
5160
},
5261
});
5362

@@ -78,6 +87,9 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
7887
e.preventDefault();
7988
if (!input.trim() || status !== "ready") return;
8089

90+
// Clear any previous error when submitting
91+
setErrorMessage("");
92+
8193
const droppedFiles = globalStore.get(model.droppedFiles);
8294

8395
// Prepare AI message parts (for backend)
@@ -209,6 +221,11 @@ const AIPanelComponent = memo(({ className, onClose }: AIPanelProps) => {
209221

210222
<div className="flex-1 flex flex-col min-h-0">
211223
<AIPanelMessages messages={messages} status={status} />
224+
{errorMessage && (
225+
<div className="px-4 py-2 text-red-400 bg-red-900/20 border-l-4 border-red-500 mx-2 mb-2">
226+
<div className="text-sm">{errorMessage}</div>
227+
</div>
228+
)}
212229
<AIDroppedFiles model={model} />
213230
<AIPanelInput
214231
ref={inputRef}

frontend/app/modals/conntypeahead.tsx

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -390,18 +390,18 @@ const ChangeConnectionBlockModal = React.memo(
390390
return;
391391
}
392392
const isAws = connName?.startsWith("aws:");
393-
const oldCwd = blockData?.meta?.file ?? "";
394-
let newCwd: string;
395-
if (oldCwd == "") {
396-
newCwd = "";
393+
const oldFile = blockData?.meta?.file ?? "";
394+
let newFile: string;
395+
if (oldFile == "") {
396+
newFile = "";
397397
} else if (isAws) {
398-
newCwd = "/";
398+
newFile = "/";
399399
} else {
400-
newCwd = "~";
400+
newFile = "~";
401401
}
402402
await RpcApi.SetMetaCommand(TabRpcClient, {
403403
oref: WOS.makeORef("block", blockId),
404-
meta: { connection: connName, file: newCwd },
404+
meta: { connection: connName, file: newFile, "cmd:cwd": null, "cmd:hascurcwd": null },
405405
});
406406
try {
407407
await RpcApi.ConnEnsureCommand(

frontend/app/view/term/termwrap.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,7 @@ function handleOsc7Command(data: string, blockId: string, loaded: boolean): bool
128128
fireAndForget(() =>
129129
services.ObjectService.UpdateObjectMeta(WOS.makeORef("block", blockId), {
130130
"cmd:cwd": data,
131+
"cmd:hascurcwd": true,
131132
})
132133
);
133134
}, 0);

frontend/types/gotypes.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -546,6 +546,7 @@ declare global {
546546
"cmd:initscript.zsh"?: string;
547547
"cmd:initscript.pwsh"?: string;
548548
"cmd:initscript.fish"?: string;
549+
"cmd:hascurcwd"?: boolean;
549550
"ai:*"?: boolean;
550551
"ai:preset"?: string;
551552
"ai:apitype"?: string;

pkg/aiusechat/anthropic/anthropic-backend.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -406,9 +406,10 @@ func RunAnthropicChatStep(
406406

407407
// pretty print json of anthropicMsgs
408408
if jsonBytes, err := json.MarshalIndent(anthropicMsgs, "", " "); err == nil {
409+
log.Printf("system-prompt: %v\n", chatOpts.SystemPrompt)
409410
log.Printf("anthropicMsgs JSON:\n%s", string(jsonBytes))
410411
} else {
411-
log.Printf("failed to marshal anthropicMsgs to JSON: %v", err)
412+
return nil, nil, fmt.Errorf("failed to marshal messages to JSON: %w", err)
412413
}
413414

414415
req, err := buildAnthropicHTTPRequest(ctx, anthropicMsgs, chatOpts)

pkg/aiusechat/tools.go

Lines changed: 117 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -6,27 +6,99 @@ package aiusechat
66
import (
77
"context"
88
"fmt"
9+
"strings"
910

1011
"github.com/google/uuid"
1112
"github.com/wavetermdev/waveterm/pkg/aiusechat/uctypes"
1213
"github.com/wavetermdev/waveterm/pkg/waveobj"
1314
"github.com/wavetermdev/waveterm/pkg/wstore"
1415
)
1516

16-
func MakeToolsForTab(ctx context.Context, tabid string, widgetAccess bool) ([]uctypes.ToolDefinition, error) {
17+
func MakeBlockShortDesc(block *waveobj.Block) string {
18+
if block.Meta == nil {
19+
return ""
20+
}
21+
22+
viewType, ok := block.Meta["view"].(string)
23+
if !ok {
24+
return ""
25+
}
26+
27+
switch viewType {
28+
case "term":
29+
connection, hasConnection := block.Meta["connection"].(string)
30+
cwd, hasCwd := block.Meta["cmd:cwd"].(string)
31+
hasCurCwd, _ := block.Meta["cmd:hascurcwd"].(bool)
32+
33+
var desc string
34+
if hasConnection && connection != "" {
35+
desc = fmt.Sprintf("CLI terminal on %q", connection)
36+
} else {
37+
desc = "local CLI terminal"
38+
}
39+
40+
if hasCurCwd && hasCwd && cwd != "" {
41+
desc += fmt.Sprintf(" in directory %q", cwd)
42+
}
43+
44+
return desc
45+
case "preview":
46+
file, hasFile := block.Meta["file"].(string)
47+
connection, hasConnection := block.Meta["connection"].(string)
48+
49+
if hasConnection && connection != "" {
50+
if hasFile && file != "" {
51+
return fmt.Sprintf("preview widget viewing %q on %q", file, connection)
52+
}
53+
return fmt.Sprintf("preview widget viewing files on %q", connection)
54+
}
55+
if hasFile && file != "" {
56+
return fmt.Sprintf("preview widget viewing %q", file)
57+
}
58+
return "file and directory preview widget"
59+
case "web":
60+
if url, hasUrl := block.Meta["url"].(string); hasUrl && url != "" {
61+
return fmt.Sprintf("web browser widget pointing at %q", url)
62+
}
63+
return "web browser widget"
64+
case "waveai":
65+
return "AI chat widget"
66+
case "cpuplot":
67+
if connection, hasConnection := block.Meta["connection"].(string); hasConnection && connection != "" {
68+
return fmt.Sprintf("cpu graph for %q", connection)
69+
}
70+
return "cpu graph"
71+
case "tips":
72+
return "Wave quick tips widget"
73+
case "help":
74+
return "Wave documentation widget"
75+
case "launcher":
76+
return "placeholder widget used to launch other widgets"
77+
case "tsunami":
78+
return "custom 'tsunami' framework widget"
79+
default:
80+
return fmt.Sprintf("unknown widget with type %q", viewType)
81+
}
82+
}
83+
84+
func AddToolsForTab(ctx context.Context, tabid string, widgetAccess bool, chatOpts *uctypes.WaveChatOpts) error {
1785
if tabid == "" {
18-
return nil, nil
86+
return nil
1987
}
20-
88+
if !widgetAccess {
89+
chatOpts.SystemPrompt = append(chatOpts.SystemPrompt, "The user has chosen not to share widget context with you.")
90+
return nil
91+
}
92+
2193
if _, err := uuid.Parse(tabid); err != nil {
22-
return nil, fmt.Errorf("tabid must be a valid UUID")
94+
return fmt.Errorf("tabid must be a valid UUID")
2395
}
24-
96+
2597
tabObj, err := wstore.DBMustGet[*waveobj.Tab](ctx, tabid)
2698
if err != nil {
27-
return nil, fmt.Errorf("error getting tab: %v", err)
99+
return fmt.Errorf("error getting tab: %v", err)
28100
}
29-
101+
30102
var blocks []*waveobj.Block
31103
for _, blockId := range tabObj.BlockIds {
32104
block, err := wstore.DBGet[*waveobj.Block](ctx, blockId)
@@ -35,8 +107,44 @@ func MakeToolsForTab(ctx context.Context, tabid string, widgetAccess bool) ([]uc
35107
}
36108
blocks = append(blocks, block)
37109
}
38-
39-
return nil, nil
110+
111+
systemPrompt := generateTabSystemPrompt(blocks)
112+
chatOpts.SystemPrompt = append(chatOpts.SystemPrompt, systemPrompt)
113+
114+
return nil
115+
}
116+
117+
func generateTabSystemPrompt(blocks []*waveobj.Block) string {
118+
if len(blocks) == 0 {
119+
return "This tab is empty with no widgets currently open."
120+
}
121+
122+
var widgetDescriptions []string
123+
for _, block := range blocks {
124+
desc := MakeBlockShortDesc(block)
125+
if desc == "" {
126+
continue
127+
}
128+
blockIdPrefix := block.OID[:8]
129+
fullDesc := fmt.Sprintf("(%s) %s", blockIdPrefix, desc)
130+
widgetDescriptions = append(widgetDescriptions, fullDesc)
131+
}
132+
133+
totalWidgets := len(widgetDescriptions)
134+
var prompt strings.Builder
135+
if totalWidgets == 1 {
136+
prompt.WriteString("In this tab there is 1 widget open (the widgetid appears in parentheses before the description):\n")
137+
} else {
138+
prompt.WriteString(fmt.Sprintf("In this tab there are %d widgets open (the widgetid appears in parentheses before the description):\n", totalWidgets))
139+
}
140+
141+
for _, desc := range widgetDescriptions {
142+
prompt.WriteString("* ")
143+
prompt.WriteString(desc)
144+
prompt.WriteString("\n")
145+
}
146+
147+
return prompt.String()
40148
}
41149

42150
func GetAdderToolDefinition() uctypes.ToolDefinition {

0 commit comments

Comments
 (0)