mirror of
https://github.com/open-jarvis/OpenJarvis.git
synced 2026-08-16 09:51:59 +00:00
Compare commits
4
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
20aa08ef04 | ||
|
|
9a63561db8 | ||
|
|
7959285ad8 | ||
|
|
26e1741059 |
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"label": "Git Clones",
|
||||
"message": "185,599",
|
||||
"message": "186,659",
|
||||
"color": "green",
|
||||
"namedLogo": "git"
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"total_clones": 185599,
|
||||
"last_updated": "2026-08-10T07:26:44Z",
|
||||
"total_clones": 186659,
|
||||
"last_updated": "2026-08-11T07:02:05Z",
|
||||
"daily": {
|
||||
"2026-03-27": 2189,
|
||||
"2026-03-28": 1874,
|
||||
@@ -137,6 +137,7 @@
|
||||
"2026-08-06": 604,
|
||||
"2026-08-07": 624,
|
||||
"2026-08-08": 706,
|
||||
"2026-08-09": 1076
|
||||
"2026-08-09": 1076,
|
||||
"2026-08-10": 1060
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,6 +121,104 @@ jobs:
|
||||
# latest release tag (#526).
|
||||
fetch-depth: 0
|
||||
|
||||
# Validate Apple credentials BEFORE the expensive work. Notarization is
|
||||
# the very last thing `tauri-action` does, so a bad credential or a
|
||||
# lapsed account agreement previously surfaced ~10 minutes in — after the
|
||||
# Rust toolchain, npm install, two Ollama sidecar downloads and a
|
||||
# universal cargo build — as a single opaque line:
|
||||
#
|
||||
# failed to bundle project: failed codesign application: failed to
|
||||
# notarize app: Error: HTTP status code: 403. ...
|
||||
#
|
||||
# `notarytool history` is a read-only call (it submits nothing) that
|
||||
# exercises the identical auth path, so every credential/account failure
|
||||
# mode reaches us here first, in seconds, with the specific cause named.
|
||||
# `xcrun` is preinstalled on macOS runners, hence placement before the
|
||||
# toolchain steps rather than next to "Configure Apple signing".
|
||||
- name: Preflight Apple notarization credentials
|
||||
if: matrix.platform == 'macos-14'
|
||||
env:
|
||||
CERT: ${{ secrets.APPLE_CERTIFICATE }}
|
||||
A_ID: ${{ secrets.APPLE_ID }}
|
||||
A_PASS: ${{ secrets.APPLE_PASSWORD }}
|
||||
A_TEAM: ${{ secrets.APPLE_TEAM_ID }}
|
||||
shell: bash
|
||||
run: |
|
||||
set -uo pipefail
|
||||
|
||||
# Mirror the skip logic in "Configure Apple signing": without a
|
||||
# certificate the build is unsigned and never notarizes, so there is
|
||||
# nothing to preflight. Tag builds still hard-fail there.
|
||||
if [ -z "$CERT" ]; then
|
||||
echo "No Apple certificate configured; skipping notarization preflight."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
missing=""
|
||||
[ -z "$A_ID" ] && missing="$missing APPLE_ID"
|
||||
[ -z "$A_PASS" ] && missing="$missing APPLE_PASSWORD"
|
||||
[ -z "$A_TEAM" ] && missing="$missing APPLE_TEAM_ID"
|
||||
if [ -n "$missing" ]; then
|
||||
echo "::error::APPLE_CERTIFICATE is set but notarization secrets are missing:$missing"
|
||||
echo "::error::Signing would succeed and notarization would then fail. Set them or clear APPLE_CERTIFICATE."
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Retry only to absorb transient network faults. Credential and
|
||||
# account errors are deterministic, so we classify and exit on the
|
||||
# first definitive answer rather than retrying into the same wall.
|
||||
attempt=1
|
||||
while [ "$attempt" -le 3 ]; do
|
||||
out=$(xcrun notarytool history \
|
||||
--apple-id "$A_ID" \
|
||||
--team-id "$A_TEAM" \
|
||||
--password "$A_PASS" \
|
||||
--output-format json 2>&1)
|
||||
rc=$?
|
||||
|
||||
if [ $rc -eq 0 ]; then
|
||||
echo "Apple notarization preflight OK — credentials valid, team reachable, agreements in effect."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
case "$out" in
|
||||
*"Invalid credentials"*|*"401"*)
|
||||
echo "::error::Apple notarization preflight failed: invalid credentials (HTTP 401)."
|
||||
echo "::error::APPLE_PASSWORD must be an app-specific password from appleid.apple.com,"
|
||||
echo "::error::generated while signed in as the SAME Apple ID as APPLE_ID. A regular"
|
||||
echo "::error::Apple ID password will not work, and a password minted under a different"
|
||||
echo "::error::Apple ID authenticates as that other account."
|
||||
exit 1
|
||||
;;
|
||||
*"Invalid or inaccessible developer team ID"*)
|
||||
echo "::error::Apple notarization preflight failed: APPLE_ID is not a member of team APPLE_TEAM_ID (HTTP 403)."
|
||||
echo "::error::The Team ID must match the signing certificate. Read it from the cert's"
|
||||
echo "::error::subject, where it appears as: Developer ID Application: NAME (TEAMID)."
|
||||
echo "::error::If you belong to several teams, confirm APPLE_ID is a member of this one."
|
||||
exit 1
|
||||
;;
|
||||
*"required agreement"*|*"agreement"*)
|
||||
echo "::error::Apple notarization preflight failed: the team has no in-effect agreement (HTTP 403)."
|
||||
echo "::error::Apple reissues the Developer Program License Agreement periodically and"
|
||||
echo "::error::notarization is refused until it is accepted. ONLY THE ACCOUNT HOLDER can"
|
||||
echo "::error::accept it — team Admins cannot. Sign in to the account that owns this team:"
|
||||
echo "::error:: 1. https://developer.apple.com/account -> review any pending agreement"
|
||||
echo "::error:: 2. App Store Connect -> Business -> accept anything pending there too"
|
||||
echo "::error::Certificates stay valid while this is outstanding, so signing still works."
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
|
||||
echo "Preflight attempt ${attempt}/3 failed with a non-credential error."
|
||||
echo "$out" | tail -5
|
||||
attempt=$((attempt + 1))
|
||||
[ "$attempt" -le 3 ] && sleep 10
|
||||
done
|
||||
|
||||
echo "::error::Apple notarization preflight failed after 3 attempts. Last output:"
|
||||
echo "$out" | tail -20
|
||||
exit 1
|
||||
|
||||
- name: Install system dependencies (Linux)
|
||||
if: matrix.platform == 'ubuntu-22.04'
|
||||
run: |
|
||||
|
||||
@@ -15,6 +15,7 @@ function getGreeting(): string {
|
||||
}
|
||||
|
||||
export function ChatArea() {
|
||||
const activeId = useAppStore((s) => s.activeId);
|
||||
const messages = useAppStore((s) => s.messages);
|
||||
const streamState = useAppStore((s) => s.streamState);
|
||||
const systemPanelOpen = useAppStore((s) => s.systemPanelOpen);
|
||||
@@ -24,6 +25,8 @@ export function ChatArea() {
|
||||
const shouldAutoScroll = useRef(true);
|
||||
const wasStreaming = useRef(false);
|
||||
const lastScrollTop = useRef(0);
|
||||
const isCurrentChatStreaming = streamState.isStreaming && streamState.conversationId === activeId;
|
||||
const currentStreamContent = isCurrentChatStreaming ? streamState.content : '';
|
||||
|
||||
// Check if any data sources are connected
|
||||
const [hasConnectedSources, setHasConnectedSources] = useState<boolean | null>(null);
|
||||
@@ -38,14 +41,14 @@ export function ChatArea() {
|
||||
useEffect(() => {
|
||||
// Sending a message always pins the view to the bottom, even if the
|
||||
// user had scrolled up to read earlier messages.
|
||||
if (streamState.isStreaming && !wasStreaming.current) {
|
||||
if (isCurrentChatStreaming && !wasStreaming.current) {
|
||||
shouldAutoScroll.current = true;
|
||||
}
|
||||
wasStreaming.current = streamState.isStreaming;
|
||||
wasStreaming.current = isCurrentChatStreaming;
|
||||
if (shouldAutoScroll.current && listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, streamState.content, streamState.isStreaming]);
|
||||
}, [messages, currentStreamContent, isCurrentChatStreaming]);
|
||||
|
||||
const handleScroll = () => {
|
||||
if (!listRef.current) return;
|
||||
@@ -66,7 +69,7 @@ export function ChatArea() {
|
||||
}
|
||||
};
|
||||
|
||||
const isEmpty = messages.length === 0 && !streamState.isStreaming;
|
||||
const isEmpty = messages.length === 0 && !isCurrentChatStreaming;
|
||||
|
||||
const PanelIcon = systemPanelOpen ? PanelRightClose : PanelRightOpen;
|
||||
|
||||
@@ -174,12 +177,12 @@ export function ChatArea() {
|
||||
<MessageBubble
|
||||
key={msg.id}
|
||||
message={msg}
|
||||
isLive={isLastAssistant && streamState.isStreaming}
|
||||
isLive={isLastAssistant && isCurrentChatStreaming}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
{(() => {
|
||||
if (!streamState.isStreaming || streamState.content !== '') return null;
|
||||
if (!isCurrentChatStreaming || streamState.content !== '') return null;
|
||||
// For research messages the ResearchTimeline handles its own
|
||||
// pre-content loading state — suppress the generic dots.
|
||||
const last = messages[messages.length - 1];
|
||||
|
||||
@@ -96,6 +96,7 @@ export function InputArea() {
|
||||
const deepResearch = useAppStore((s) => s.deepResearch);
|
||||
const setDeepResearch = useAppStore((s) => s.setDeepResearch);
|
||||
const corpusSync = useResearchCorpusSync(deepResearch);
|
||||
const isCurrentChatStreaming = streamState.isStreaming && streamState.conversationId === activeId;
|
||||
|
||||
const {
|
||||
state: speechState,
|
||||
@@ -226,6 +227,7 @@ export function InputArea() {
|
||||
let ttftMs: number | undefined;
|
||||
|
||||
setStreamState({
|
||||
conversationId: convId,
|
||||
isStreaming: true,
|
||||
phase: deepResearch ? 'Researching...' : 'Generating...',
|
||||
elapsedMs: 0,
|
||||
@@ -602,7 +604,7 @@ export function InputArea() {
|
||||
style={{ color: 'var(--color-text)', maxHeight: '200px' }}
|
||||
disabled={streamState.isStreaming || modelLoading}
|
||||
/>
|
||||
{streamState.isStreaming ? (
|
||||
{isCurrentChatStreaming ? (
|
||||
<button
|
||||
onClick={stopStreaming}
|
||||
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer"
|
||||
@@ -621,7 +623,7 @@ export function InputArea() {
|
||||
/>
|
||||
<button
|
||||
onClick={sendMessage}
|
||||
disabled={!input.trim() || modelLoading || !selectedModel}
|
||||
disabled={streamState.isStreaming || !input.trim() || modelLoading || !selectedModel}
|
||||
title={selectedModel ? 'Send message' : 'Pick a model first (⌘K)'}
|
||||
className="p-2 rounded-xl transition-colors shrink-0 cursor-pointer disabled:opacity-30 disabled:cursor-default"
|
||||
style={{
|
||||
|
||||
@@ -22,6 +22,9 @@ export function ConversationList({ searchQuery }: Props) {
|
||||
const navigate = useNavigate();
|
||||
const conversations = useAppStore((s) => s.conversations);
|
||||
const activeId = useAppStore((s) => s.activeId);
|
||||
const streamingConversationId = useAppStore((s) =>
|
||||
s.streamState.isStreaming ? s.streamState.conversationId : null,
|
||||
);
|
||||
const selectConversation = useAppStore((s) => s.selectConversation);
|
||||
const deleteConversation = useAppStore((s) => s.deleteConversation);
|
||||
|
||||
@@ -43,6 +46,7 @@ export function ConversationList({ searchQuery }: Props) {
|
||||
<div className="flex flex-col gap-0.5 py-1">
|
||||
{filtered.map((conv) => {
|
||||
const isActive = conv.id === activeId;
|
||||
const isStreaming = conv.id === streamingConversationId;
|
||||
return (
|
||||
<div
|
||||
key={conv.id}
|
||||
@@ -82,11 +86,18 @@ export function ConversationList({ searchQuery }: Props) {
|
||||
e.stopPropagation();
|
||||
deleteConversation(conv.id);
|
||||
}}
|
||||
className="p-1.5 mr-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer"
|
||||
disabled={isStreaming}
|
||||
className="p-1.5 mr-1 rounded opacity-0 group-hover:opacity-100 transition-opacity cursor-pointer disabled:cursor-not-allowed disabled:opacity-30"
|
||||
style={{ color: 'var(--color-text-tertiary)' }}
|
||||
onMouseEnter={(e) => (e.currentTarget.style.color = 'var(--color-error)')}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isStreaming) e.currentTarget.style.color = 'var(--color-error)';
|
||||
}}
|
||||
onMouseLeave={(e) => (e.currentTarget.style.color = 'var(--color-text-tertiary)')}
|
||||
title="Delete conversation"
|
||||
title={
|
||||
isStreaming
|
||||
? 'Stop generating before deleting this conversation'
|
||||
: 'Delete conversation'
|
||||
}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
class MemoryStorage {
|
||||
private store = new Map<string, string>();
|
||||
|
||||
getItem(key: string): string | null {
|
||||
return this.store.get(key) ?? null;
|
||||
}
|
||||
|
||||
setItem(key: string, value: string): void {
|
||||
this.store.set(key, String(value));
|
||||
}
|
||||
|
||||
removeItem(key: string): void {
|
||||
this.store.delete(key);
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.resetModules();
|
||||
(globalThis as unknown as { localStorage: MemoryStorage }).localStorage =
|
||||
new MemoryStorage();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
(globalThis as unknown as { localStorage?: MemoryStorage }).localStorage =
|
||||
undefined;
|
||||
});
|
||||
|
||||
async function freshStore() {
|
||||
return (await import('./store')).useAppStore;
|
||||
}
|
||||
|
||||
describe('conversation stream ownership', () => {
|
||||
it('persists background stream updates without replacing the active messages', async () => {
|
||||
const store = await freshStore();
|
||||
const sourceId = store.getState().createConversation('test-model');
|
||||
store.getState().addMessage(sourceId, {
|
||||
id: 'assistant',
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: 1,
|
||||
});
|
||||
|
||||
const activeId = store.getState().createConversation('test-model');
|
||||
store.getState().setStreamState({
|
||||
conversationId: sourceId,
|
||||
isStreaming: true,
|
||||
content: 'streamed response',
|
||||
});
|
||||
store.getState().updateLastAssistant(sourceId, 'streamed response');
|
||||
|
||||
expect(store.getState().activeId).toBe(activeId);
|
||||
expect(store.getState().messages).toEqual([]);
|
||||
|
||||
store.getState().selectConversation(sourceId);
|
||||
expect(store.getState().messages).toHaveLength(1);
|
||||
expect(store.getState().messages[0].content).toBe('streamed response');
|
||||
});
|
||||
|
||||
it('keeps the stream-owning conversation until generation stops', async () => {
|
||||
const store = await freshStore();
|
||||
const sourceId = store.getState().createConversation('test-model');
|
||||
const activeId = store.getState().createConversation('test-model');
|
||||
store.getState().setStreamState({
|
||||
conversationId: sourceId,
|
||||
isStreaming: true,
|
||||
});
|
||||
|
||||
store.getState().deleteConversation(sourceId);
|
||||
expect(
|
||||
store.getState().conversations.map((conversation) => conversation.id),
|
||||
).toContain(sourceId);
|
||||
expect(store.getState().activeId).toBe(activeId);
|
||||
|
||||
store.getState().resetStream();
|
||||
store.getState().deleteConversation(sourceId);
|
||||
expect(
|
||||
store.getState().conversations.map((conversation) => conversation.id),
|
||||
).not.toContain(sourceId);
|
||||
});
|
||||
});
|
||||
@@ -111,6 +111,7 @@ function saveSettings(settings: Settings): void {
|
||||
// ── Store ─────────────────────────────────────────────────────────────
|
||||
|
||||
const INITIAL_STREAM: StreamState = {
|
||||
conversationId: null,
|
||||
isStreaming: false,
|
||||
phase: '',
|
||||
elapsedMs: 0,
|
||||
@@ -352,6 +353,9 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
},
|
||||
|
||||
deleteConversation: (id: string) => {
|
||||
const streamState = get().streamState;
|
||||
if (streamState.isStreaming && streamState.conversationId === id) return;
|
||||
|
||||
const store = loadConversations();
|
||||
delete store.conversations[id];
|
||||
if (store.activeId === id) {
|
||||
@@ -394,12 +398,14 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
(message.content.length > 50 ? '...' : '');
|
||||
}
|
||||
saveConversations(store);
|
||||
set({
|
||||
messages: [...conv.messages],
|
||||
conversations: Object.values(store.conversations).sort(
|
||||
(a, b) => b.updatedAt - a.updatedAt,
|
||||
),
|
||||
});
|
||||
const conversations = Object.values(store.conversations).sort(
|
||||
(a, b) => b.updatedAt - a.updatedAt,
|
||||
);
|
||||
if (get().activeId === conversationId) {
|
||||
set({ messages: [...conv.messages], conversations });
|
||||
} else {
|
||||
set({ conversations });
|
||||
}
|
||||
},
|
||||
|
||||
updateLastAssistant: (
|
||||
@@ -426,7 +432,9 @@ export const useAppStore = create<AppState>((set, get) => {
|
||||
if (researchSources) lastMsg.researchSources = researchSources;
|
||||
conv.updatedAt = Date.now();
|
||||
saveConversations(store);
|
||||
set({ messages: [...conv.messages] });
|
||||
if (get().activeId === conversationId) {
|
||||
set({ messages: [...conv.messages] });
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
|
||||
@@ -147,6 +147,7 @@ export interface ConversationStore {
|
||||
// --- Stream State ---
|
||||
|
||||
export interface StreamState {
|
||||
conversationId: string | null;
|
||||
isStreaming: boolean;
|
||||
phase: string;
|
||||
elapsedMs: number;
|
||||
|
||||
@@ -11,7 +11,9 @@ Three install paths are supported today:
|
||||
- **Editable git checkout** (``uv sync`` / ``pip install -e .`` from a
|
||||
cloned repo). The package's ``__file__`` is inside a working tree
|
||||
with a ``.git`` directory at the repo root. Upgrade with
|
||||
``git pull && uv sync`` from the checkout.
|
||||
``git pull && uv sync --inexact`` from the checkout. ``--inexact`` is
|
||||
important here: a bare ``uv sync`` removes packages installed by extras or
|
||||
dependency groups that are not part of the base project.
|
||||
|
||||
We detect by inspecting ``openjarvis.__file__``. If we can't tell with
|
||||
confidence we fall back to the PyPI command — that's the most common
|
||||
@@ -68,7 +70,7 @@ def detect_install() -> InstallInfo:
|
||||
if (candidate / ".git").exists() and (candidate / "pyproject.toml").exists():
|
||||
return InstallInfo(
|
||||
kind="editable-git",
|
||||
upgrade_command=f"cd {candidate} && git pull && uv sync",
|
||||
upgrade_command=(f"cd {candidate} && git pull && uv sync --inexact"),
|
||||
repo_root=candidate,
|
||||
)
|
||||
if candidate.parent == candidate:
|
||||
|
||||
@@ -4,7 +4,9 @@ Runs the right upgrade command for how the user installed OpenJarvis:
|
||||
|
||||
- PyPI installs get ``pip install --upgrade openjarvis``.
|
||||
- uv-tool installs get ``uv tool upgrade openjarvis``.
|
||||
- Editable git checkouts get ``git pull && uv sync`` in the checkout.
|
||||
- Editable git checkouts get ``git pull && uv sync --inexact`` in the checkout.
|
||||
The inexact sync preserves packages previously installed through extras and
|
||||
dependency groups.
|
||||
|
||||
The detection logic is shared with the post-command "new version
|
||||
available" hint in ``_version_check.py`` so both surfaces stay in sync.
|
||||
|
||||
@@ -35,7 +35,7 @@ def test_editable_git_install_detected(tmp_path, monkeypatch):
|
||||
info = detect_install()
|
||||
assert info.kind == "editable-git"
|
||||
assert "git pull" in info.upgrade_command
|
||||
assert "uv sync" in info.upgrade_command
|
||||
assert info.upgrade_command.endswith("uv sync --inexact")
|
||||
assert info.repo_root == repo
|
||||
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ def _mock_info(kind: str = "pypi") -> InstallInfo:
|
||||
upgrade_command={
|
||||
"pypi": "pip install --upgrade openjarvis",
|
||||
"uv-tool": "uv tool upgrade openjarvis",
|
||||
"editable-git": "cd /tmp/repo && git pull && uv sync",
|
||||
"editable-git": "cd /tmp/repo && git pull && uv sync --inexact",
|
||||
"unknown": "pip install --upgrade openjarvis",
|
||||
}[kind],
|
||||
)
|
||||
@@ -90,6 +90,26 @@ def test_editable_git_uses_shell_true():
|
||||
assert kwargs.get("shell") is True
|
||||
|
||||
|
||||
def test_editable_git_preserves_extra_dependencies():
|
||||
"""The update sync must not remove packages from prior extras/groups."""
|
||||
mock_proc = MagicMock(returncode=0)
|
||||
with (
|
||||
patch(
|
||||
"openjarvis.cli.self_update_cmd.detect_install",
|
||||
return_value=_mock_info("editable-git"),
|
||||
),
|
||||
patch(
|
||||
"openjarvis.cli.self_update_cmd.subprocess.run",
|
||||
return_value=mock_proc,
|
||||
) as mock_run,
|
||||
):
|
||||
result = CliRunner().invoke(self_update, ["-y"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "uv sync --inexact" in result.output
|
||||
assert "uv sync --inexact" in mock_run.call_args.args[0]
|
||||
|
||||
|
||||
def test_failed_upgrade_propagates_exit_code():
|
||||
mock_proc = MagicMock(returncode=3)
|
||||
with (
|
||||
|
||||
Reference in New Issue
Block a user