Skip to content
Draft
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
35 changes: 35 additions & 0 deletions src/modules/ai/lib/composer.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
useRef,
useState,
} from "react";
import type { UIMessage } from "@ai-sdk/react";
import { useWhisperRecording } from "../hooks/useWhisperRecording";
import { expandSnippetTokens, type Snippet } from "../lib/snippets";
import { tryRunSlashCommand, type SlashCommandMeta } from "./slashCommands";
Expand Down Expand Up @@ -232,6 +233,40 @@ export function AiComposerProvider({ children }: ProviderProps) {
if (outcome.toast) console.info(outcome.toast);
return;
}
if (outcome.kind === "local-run") {
const sid = sessionId;
if (!sid) return;
const inputText = commandSource;
setValue("");
setFiles([]);
setPickedSnippets([]);
setPickedCommands([]);
void (async () => {
const chat = getOrCreateChat(sid);
const userMsg: UIMessage = {
id: crypto.randomUUID(),
role: "user",
parts: [{ type: "text", text: inputText }],
};
chat.messages = [...chat.messages, userMsg];
let resultText: string;
try {
resultText = await outcome.execute();
} catch (e) {
resultText = `Error: ${String(e)}`;
}
const asstMsg: UIMessage = {
id: crypto.randomUUID(),
role: "assistant",
parts: [{ type: "text", text: resultText }],
};
chat.messages = [...chat.messages, asstMsg];
if (!useChatStore.getState().mini.open) {
useChatStore.getState().openMini();
}
})();
return;
}
if (outcome.kind === "send-prompt") {
effectiveText = outcome.prompt;
if (outcome.commandName) {
Expand Down
42 changes: 41 additions & 1 deletion src/modules/ai/lib/slashCommands.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import { CheckListIcon, SparklesIcon } from "@hugeicons/core-free-icons";
import {
CheckListIcon,
Exchange01Icon,
SparklesIcon,
Sun01Icon,
} from "@hugeicons/core-free-icons";
import { usePlanStore } from "../store/planStore";
import { lookupExchangeRate, lookupWeather } from "./webLookup";

/**
* Outcome of intercepting a slash command from the composer.
Expand All @@ -10,6 +16,7 @@ import { usePlanStore } from "../store/planStore";
*/
export type SlashOutcome =
| { kind: "handled"; toast?: string }
| { kind: "local-run"; execute: () => Promise<string> }
| { kind: "send-prompt"; prompt: string; commandName?: string }
| { kind: "none" };

Expand Down Expand Up @@ -43,6 +50,18 @@ export const SLASH_COMMANDS: Record<string, SlashCommandMeta> = {
label: "Plan mode",
icon: CheckListIcon,
},
weather: {
name: "weather",
invocation: "/weather",
label: "Get weather",
icon: Sun01Icon,
},
exchange: {
name: "exchange",
invocation: "/exchange",
label: "Exchange rate",
icon: Exchange01Icon,
},
};

export const TERAX_CMD_RE =
Expand Down Expand Up @@ -81,6 +100,27 @@ export function tryRunSlashCommand(input: string): SlashOutcome {
commandName: "init",
};
}
case "weather": {
const city = tail || "London";
const parts = city.split(/\s+/);
const units =
parts[parts.length - 1] === "imperial" ? "imperial" : "metric";
const cityName =
units === "imperial" ? parts.slice(0, -1).join(" ") || city : city;
return {
kind: "local-run",
execute: () => lookupWeather(cityName, units),
};
}
case "exchange": {
const [from = "USD", to = "EUR", amtStr] = tail.split(/\s+/);
const amount = amtStr ? parseFloat(amtStr) : 1;
return {
kind: "local-run",
execute: () =>
lookupExchangeRate(from, to, Number.isFinite(amount) ? amount : 1),
};
}
default:
return { kind: "none" };
}
Expand Down
99 changes: 99 additions & 0 deletions src/modules/ai/lib/webLookup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { invoke } from "@tauri-apps/api/core";
import { KEYRING_SERVICE } from "../config";

type HttpResp = { status: number; body: number[] };

async function getJson(url: string): Promise<unknown> {
const resp = await invoke<HttpResp>("ai_http_request", {
url,
method: "GET",
headers: null,
body: null,
});
if (resp.status < 200 || resp.status >= 300) {
throw new Error(`HTTP ${resp.status}`);
}
return JSON.parse(new TextDecoder().decode(new Uint8Array(resp.body)));
}

export async function lookupWeather(
city: string,
units: "metric" | "imperial" = "metric",
): Promise<string> {
let apiKey: string | null = null;
try {
apiKey = await invoke<string | null>("secrets_get", {
service: KEYRING_SERVICE,
account: "openweathermap",
});
} catch {
// key absent from keychain
}

if (!apiKey) {
return "OpenWeatherMap API key not set. Add it in **Settings → API Keys** with account name `openweathermap`. Get a free key at openweathermap.org.";
}

const url = `https://api.openweathermap.org/data/2.5/weather?q=${encodeURIComponent(city)}&appid=${apiKey}&units=${units}`;
const data = (await getJson(url)) as {
name: string;
sys: { country: string };
weather: { description: string }[];
main: {
temp: number;
feels_like: number;
temp_min: number;
temp_max: number;
humidity: number;
};
wind: { speed: number };
visibility: number;
};

const unit = units === "metric" ? "°C" : "°F";
const speedUnit = units === "metric" ? "m/s" : "mph";

return [
`**${data.name}, ${data.sys.country}** — ${data.weather[0]?.description ?? "unknown"}`,
``,
`| | |`,
`|---|---|`,
`| Temperature | ${Math.round(data.main.temp)}${unit} (feels ${Math.round(data.main.feels_like)}${unit}) |`,
`| Hi / Lo | ${Math.round(data.main.temp_max)}${unit} / ${Math.round(data.main.temp_min)}${unit} |`,
`| Humidity | ${data.main.humidity}% |`,
`| Wind | ${data.wind.speed} ${speedUnit} |`,
`| Visibility | ${(data.visibility / 1000).toFixed(1)} km |`,
].join("\n");
}

export async function lookupExchangeRate(
from: string,
to: string,
amount: number = 1,
): Promise<string> {
const fromCode = from.toUpperCase();
const toCode = to.toUpperCase();

const data = (await getJson(
`https://api.frankfurter.app/latest?from=${fromCode}&to=${toCode}`,
)) as {
base: string;
date: string;
rates: Record<string, number>;
};

const rate = data.rates[toCode];
if (rate === undefined) {
return `Currency **${toCode}** not supported. Frankfurter supports ~33 major currencies (USD, EUR, GBP, INR, JPY, AUD, CAD, CHF, CNY…).`;
}

const lines = [
`**${fromCode} → ${toCode}** · as of ${data.date}`,
``,
`1 ${fromCode} = ${rate.toFixed(6)} ${toCode}`,
];
if (amount !== 1) {
lines.push(`${amount} ${fromCode} = ${(amount * rate).toFixed(2)} ${toCode}`);
}
return lines.join("\n");
}