Skip to content
Merged
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
7 changes: 7 additions & 0 deletions packages/extension/src/background/rpc-handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -162,6 +162,13 @@ export async function dispatch(req: RpcRequest): Promise<Json> {
}
return null;
}
case "widget.openSidepanelWithSave": {
await chrome.sidePanel.open({ tabId: req.tabId });
await chrome.storage.session.set({
"caiji.pendingSave": { tabId: req.tabId, ts: Date.now() }
});
return null;
}
case "widget.markHostHidden": {
const KEY = "caiji.widget.hiddenHosts";
const raw = (await chrome.storage.local.get([KEY]))[KEY];
Expand Down
35 changes: 35 additions & 0 deletions packages/extension/src/content/widget/element-capture-hook.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { useEffect } from "react";

/**
* Widget-side hook for element-capture flow:
* - startCapture() 触发页面进入圈选模式(content/element-capture.ts 监听)
* - 用户点选后 element-capture 发 atwebpilot.captureResult 消息
* - 本 hook 挂 chrome.runtime.onMessage listener,收到就调 onSelector
*/
export function useElementCapture(onSelector: (selector: string) => void): {
startCapture: () => void;
} {
useEffect(() => {
function listener(msg: unknown) {
const m = msg as { type?: string; selector?: string } | null;
if (!m || m.type !== "atwebpilot.captureResult") return;
if (typeof m.selector === "string") onSelector(m.selector);
}
try {
chrome.runtime.onMessage.addListener(listener);
} catch { /* no chrome in test */ }
return () => {
try {
chrome.runtime.onMessage.removeListener(listener);
} catch { /* noop */ }
};
}, [onSelector]);

function startCapture(): void {
try {
chrome.runtime.sendMessage({ type: "atwebpilot.startCapture" });
} catch { /* noop */ }
}

return { startCapture };
}
44 changes: 44 additions & 0 deletions packages/extension/src/content/widget/empty-state.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { EmptySuggestions } from "@/sidepanel/chat/empty-suggestions";
import { QuickActions } from "@/sidepanel/chat/quick-actions";
import { matchPresetsByUrl } from "@atwebpilot/shared/match-presets";
import type { Preset } from "@atwebpilot/shared/preset";
import type { SessionData } from "@/sidepanel/chat/session-store";

type Props = {
session: SessionData;
onFillInput: (text: string) => void;
};

/**
* Widget 空态:URL 命中 preset 时展示 chip 卡片 + QuickActions 默认 3 条。
* 点击任何一条 → 把对应文本塞进 input(不 auto-send)让用户可修改。
*/
export function EmptyState({ session, onFillInput }: Props) {
const url = session.url;
const presets = url ? matchPresetsByUrl(url) : [];

function onPresetPick(p: Preset) {
// tool-form preset:首版降级为让 AI 自主挑对应保存工具
if (p.kind === "prompt") {
onFillInput(p.prompt);
} else {
onFillInput(`运行 preset "${p.name}"`);
}
}

return (
<div className="p-3 space-y-3 text-xs text-zinc-400">
{presets.length > 0 && (
<EmptySuggestions
matchedTools={[]}
onRun={() => {}}
onDetail={() => {}}
presets={presets}
onPresetPick={onPresetPick}
/>
)}
<QuickActions currentUrl={url || undefined} onPick={onFillInput} />
<div className="text-center text-zinc-500 pt-2">告诉 AI 你想让它做什么</div>
</div>
);
}
25 changes: 25 additions & 0 deletions packages/extension/src/content/widget/error-banner.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { X, AlertTriangle } from "lucide-react";
import { setError } from "@/sidepanel/chat/session-store";
import type { SessionData } from "@/sidepanel/chat/session-store";

type Props = { session: SessionData; tabId: number };

export function ErrorBanner({ session, tabId }: Props) {
if (!session.errorMessage) return null;
return (
<div
data-testid="widget-error-banner"
className="px-3 py-1.5 bg-red-950 border-b border-red-900 text-[11px] text-red-200 flex items-start gap-2 shrink-0"
>
<AlertTriangle size={12} className="shrink-0 mt-0.5" />
<span className="flex-1 break-words">{session.errorMessage}</span>
<button
aria-label="关闭错误提示"
className="shrink-0 hover:text-red-100"
onClick={() => setError(tabId, null)}
>
<X size={12} />
</button>
</div>
);
}
104 changes: 104 additions & 0 deletions packages/extension/src/content/widget/history-mode.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import { useEffect, useState } from "react";
import { Clock } from "lucide-react";
import {
listArchivedByUrl, restoreArchived,
} from "@/sidepanel/chat/persistence/sessions-storage";

type ArchivedRow = {
id: string;
url: string;
updatedAt: number;
messageCount: number;
stepCount: number;
status: string;
title: string;
};

type Props = {
url: string;
tabId: number;
onBack: () => void;
};

function truncate(s: string, n: number): string {
if (s.length <= n) return s;
return s.slice(0, n - 1) + "…";
}

function relativeTime(ts: number): string {
const diffMs = Date.now() - ts;
const diffSec = Math.floor(diffMs / 1000);
if (diffSec < 60) return `${diffSec}s 前`;
const diffMin = Math.floor(diffSec / 60);
if (diffMin < 60) return `${diffMin}m 前`;
const diffHr = Math.floor(diffMin / 60);
if (diffHr < 24) return `${diffHr}h 前`;
const diffDay = Math.floor(diffHr / 24);
return `${diffDay}d 前`;
}

export function HistoryMode({ url, tabId, onBack }: Props) {
const [rows, setRows] = useState<ArchivedRow[] | null>(null);

useEffect(() => {
let cancelled = false;
listArchivedByUrl(url).then((list) => {
if (cancelled) return;
const mapped: ArchivedRow[] = list.map((s) => {
const data = (s.data ?? {}) as { messages?: any[]; executedSteps?: any[]; status?: string };
const msgs = data.messages ?? [];
const firstUser = msgs.find((m: any) => m.role === "user");
const firstText = typeof firstUser?.content === "string"
? firstUser.content
: (firstUser?.content?.find?.((p: any) => p.type === "text")?.text ?? "");
return {
id: s.id,
url: s.url,
updatedAt: s.updatedAt ?? s.createdAt ?? 0,
messageCount: msgs.length,
stepCount: (data.executedSteps ?? []).length,
status: data.status ?? "unknown",
title: firstText ? truncate(firstText, 30) : "(无标题)",
};
}).sort((a, b) => b.updatedAt - a.updatedAt);
setRows(mapped);
}).catch(() => setRows([]));
return () => { cancelled = true; };
}, [url]);

async function onRestore(id: string) {
await restoreArchived(id, tabId);
onBack();
}

return (
<div
data-testid="widget-history-mode"
className="flex flex-col h-full overflow-hidden"
>
<div className="px-3 py-2 border-b border-zinc-800 flex items-center gap-2 text-xs text-zinc-400">
<Clock size={12} />
<span>本 URL 历史对话({rows?.length ?? "…"})</span>
</div>
<div className="flex-1 overflow-auto p-2 space-y-2">
{rows == null && <div className="text-zinc-500 text-[11px] text-center pt-4">加载中…</div>}
{rows && rows.length === 0 && (
<div className="text-zinc-500 text-[11px] text-center pt-4">此 URL 无历史会话</div>
)}
{rows?.map((r) => (
<button
key={r.id}
data-testid="widget-history-row"
className="w-full text-left px-3 py-2 bg-zinc-900 hover:bg-zinc-800 border border-zinc-800 rounded"
onClick={() => void onRestore(r.id)}
>
<div className="text-zinc-200 text-[12px] font-medium truncate">{r.title}</div>
<div className="text-zinc-500 text-[10px] mt-0.5">
{r.messageCount} 条消息 · {r.stepCount} 步 · {r.status} · {relativeTime(r.updatedAt)}
</div>
</button>
))}
</div>
</div>
);
}
95 changes: 95 additions & 0 deletions packages/extension/src/content/widget/input-row.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
import { Send, Square } from "lucide-react";
import { PermissionModePill } from "@/sidepanel/input/permission-mode-pill";
import { StagedImages } from "@/sidepanel/components/staged-images";
import { InputBox } from "@/sidepanel/input/input-box";
import { fileToImagePart, MAX_IMAGE_BYTES, MAX_IMAGES_PER_TURN } from "@/sidepanel/lib/image-utils";
import { setPermissionMode } from "@/sidepanel/chat/session-store";
import { useSettings } from "@/sidepanel/chat/settings-store";
import type { ImagePart } from "@atwebpilot/shared/types";
import type { SessionData } from "@/sidepanel/chat/session-store";
import type { PermissionMode } from "@/sidepanel/chat/severity";

type Props = {
session: SessionData;
tabId: number;
input: string;
onInputChange: (v: string) => void;
onSubmit: () => void;
onStop: () => void;
stagedImages: ImagePart[];
onSetStagedImages: (imgs: ImagePart[]) => void;
disabled: boolean;
isBusy: boolean;
};

export function InputRow({
session, tabId, input, onInputChange,
onSubmit, onStop, stagedImages, onSetStagedImages,
disabled, isBusy,
}: Props) {
const trustedDangerTools = useSettings((s) => s.trustedDangerTools);
const saveSettings = useSettings((s) => s.save);

const canSend = !isBusy && (input.trim().length > 0 || stagedImages.length > 0);

async function handleImageFiles(files: File[]) {
const room = Math.max(0, MAX_IMAGES_PER_TURN - stagedImages.length);
const accepted = files
.filter((f) => f.size <= MAX_IMAGE_BYTES)
.slice(0, room);
const parts = await Promise.all(accepted.map(fileToImagePart));
onSetStagedImages([...stagedImages, ...parts]);
}

return (
<div className="flex flex-col shrink-0">
{/* Pill row */}
<div className="flex items-center gap-2 px-2 py-1 border-t border-zinc-800">
<PermissionModePill
mode={session.permissionMode as PermissionMode}
onChange={(m) => setPermissionMode(tabId, m)}
trustedDangerTools={trustedDangerTools}
onTrustedChange={(next) => void saveSettings({ trustedDangerTools: next })}
/>
</div>
{/* Staged images (renders null if empty) */}
<StagedImages
images={stagedImages}
onRemove={(idx) => onSetStagedImages(stagedImages.filter((_, i) => i !== idx))}
/>
{/* Input + send/stop */}
<div className="flex items-end gap-2 p-2">
<div className="flex-1">
<InputBox
value={input}
onChange={onInputChange}
onSubmit={onSubmit}
onImageFiles={handleImageFiles}
disabled={disabled}
placeholder="告诉 AI 你要做什么…"
/>
</div>
{isBusy ? (
<button
data-testid="widget-stop-btn"
onClick={onStop}
title="停止"
className="h-9 px-2 bg-red-800 hover:bg-red-700 rounded text-red-100"
>
<Square size={14} />
</button>
) : (
<button
data-testid="widget-send-btn"
onClick={onSubmit}
disabled={!canSend}
title="发送"
className="h-9 px-2 bg-emerald-700 hover:bg-emerald-600 rounded text-emerald-100 disabled:opacity-40 disabled:cursor-not-allowed"
>
<Send size={14} />
</button>
)}
</div>
</div>
);
}
Loading
Loading